From 12ecffdabe14de5bcc227157ad5e9bb036c36a90 Mon Sep 17 00:00:00 2001 From: Forbes Date: Wed, 18 Feb 2026 19:38:20 -0600 Subject: [PATCH] feat(api): approvals + ECO workflow API with YAML-configurable workflows - Add internal/workflow/ package for YAML workflow definitions (Load, LoadAll, Validate) - Add internal/db/item_approvals.go repository (Create, AddSignature, GetWithSignatures, ListByItemWithSignatures, UpdateState, UpdateSignature) - Add internal/api/approval_handlers.go with 4 endpoints: - GET /{partNumber}/approvals (list approvals with signatures) - POST /{partNumber}/approvals (create ECO with workflow + signers) - POST /{partNumber}/approvals/{id}/sign (approve or reject) - GET /workflows (list available workflow definitions) - Rule-driven state transitions: any_reject and all_required_approve - Pack approvals into silo/approvals.json on .kc checkout - Add WorkflowsConfig to config, load workflows at startup - Migration 019: add workflow_name column to item_approvals - Example workflows: engineering-change.yaml, quick-review.yaml - 7 workflow tests, all passing Closes #145 --- cmd/silod/main.go | 16 +- internal/api/approval_handlers.go | 391 ++++++++++++++++++++++ internal/api/handlers.go | 7 + internal/api/pack_handlers.go | 47 +++ internal/api/routes.go | 6 + internal/config/config.go | 27 +- internal/db/item_approvals.go | 212 ++++++++++++ internal/kc/kc.go | 21 ++ internal/kc/pack.go | 5 + internal/workflow/workflow.go | 156 +++++++++ internal/workflow/workflow_test.go | 167 +++++++++ migrations/019_approval_workflow_name.sql | 2 + workflows/engineering-change.yaml | 25 ++ workflows/quick-review.yaml | 19 ++ 14 files changed, 1091 insertions(+), 10 deletions(-) create mode 100644 internal/api/approval_handlers.go create mode 100644 internal/db/item_approvals.go create mode 100644 internal/workflow/workflow.go create mode 100644 internal/workflow/workflow_test.go create mode 100644 migrations/019_approval_workflow_name.sql create mode 100644 workflows/engineering-change.yaml create mode 100644 workflows/quick-review.yaml diff --git a/cmd/silod/main.go b/cmd/silod/main.go index db79642..2054c56 100644 --- a/cmd/silod/main.go +++ b/cmd/silod/main.go @@ -23,6 +23,7 @@ import ( "github.com/kindredsystems/silo/internal/modules" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/storage" + "github.com/kindredsystems/silo/internal/workflow" "github.com/rs/zerolog" ) @@ -235,6 +236,19 @@ func main() { } } + // Load approval workflow definitions (optional — directory may not exist yet) + var workflows map[string]*workflow.Workflow + if _, err := os.Stat(cfg.Workflows.Directory); err == nil { + workflows, err = workflow.LoadAll(cfg.Workflows.Directory) + if err != nil { + logger.Fatal().Err(err).Str("directory", cfg.Workflows.Directory).Msg("failed to load workflow definitions") + } + logger.Info().Int("count", len(workflows)).Msg("loaded workflow definitions") + } else { + workflows = make(map[string]*workflow.Workflow) + logger.Info().Str("directory", cfg.Workflows.Directory).Msg("workflows directory not found, skipping") + } + // Initialize module registry registry := modules.NewRegistry() if err := modules.LoadState(registry, cfg, database.Pool()); err != nil { @@ -258,7 +272,7 @@ func main() { // Create API server server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store, authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState, - jobDefs, cfg.Jobs.Directory, registry, cfg) + jobDefs, cfg.Jobs.Directory, registry, cfg, workflows) router := api.NewRouter(server, logger) // Start background sweepers for job/runner timeouts (only when jobs module enabled) diff --git a/internal/api/approval_handlers.go b/internal/api/approval_handlers.go new file mode 100644 index 0000000..d716f6b --- /dev/null +++ b/internal/api/approval_handlers.go @@ -0,0 +1,391 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/workflow" +) + +// ApprovalResponse is the JSON representation for approval endpoints. +type ApprovalResponse struct { + ID string `json:"id"` + WorkflowName string `json:"workflow"` + ECONumber *string `json:"eco_number"` + State string `json:"state"` + UpdatedAt string `json:"updated_at"` + UpdatedBy *string `json:"updated_by"` + Signatures []SignatureResponse `json:"signatures"` +} + +// SignatureResponse is the JSON representation for a signature. +type SignatureResponse struct { + Username string `json:"username"` + Role string `json:"role"` + Status string `json:"status"` + SignedAt *string `json:"signed_at"` + Comment *string `json:"comment"` +} + +// CreateApprovalRequest is the JSON body for POST /approvals. +type CreateApprovalRequest struct { + Workflow string `json:"workflow"` + ECONumber string `json:"eco_number"` + Signers []SignerRequest `json:"signers"` +} + +// SignerRequest defines a signer in the create request. +type SignerRequest struct { + Username string `json:"username"` + Role string `json:"role"` +} + +// SignApprovalRequest is the JSON body for POST /approvals/{id}/sign. +type SignApprovalRequest struct { + Status string `json:"status"` + Comment *string `json:"comment"` +} + +func approvalToResponse(a *db.ItemApproval) ApprovalResponse { + sigs := make([]SignatureResponse, len(a.Signatures)) + for i, s := range a.Signatures { + var signedAt *string + if s.SignedAt != nil { + t := s.SignedAt.UTC().Format("2006-01-02T15:04:05Z") + signedAt = &t + } + sigs[i] = SignatureResponse{ + Username: s.Username, + Role: s.Role, + Status: s.Status, + SignedAt: signedAt, + Comment: s.Comment, + } + } + return ApprovalResponse{ + ID: a.ID, + WorkflowName: a.WorkflowName, + ECONumber: a.ECONumber, + State: a.State, + UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedBy: a.UpdatedBy, + Signatures: sigs, + } +} + +// HandleGetApprovals returns all approvals with signatures for an item. +// GET /api/items/{partNumber}/approvals +func (s *Server) HandleGetApprovals(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + approvals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list approvals") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list approvals") + return + } + + resp := make([]ApprovalResponse, len(approvals)) + for i, a := range approvals { + resp[i] = approvalToResponse(a) + } + + writeJSON(w, http.StatusOK, resp) +} + +// HandleCreateApproval creates an ECO with a workflow and signers. +// POST /api/items/{partNumber}/approvals +func (s *Server) HandleCreateApproval(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req CreateApprovalRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + + if len(req.Signers) == 0 { + writeError(w, http.StatusBadRequest, "invalid_body", "At least one signer is required") + return + } + + // Validate workflow exists + wf, ok := s.workflows[req.Workflow] + if !ok { + writeError(w, http.StatusBadRequest, "invalid_workflow", "Workflow '"+req.Workflow+"' not found") + return + } + + // Validate each signer's role matches a gate in the workflow + for _, signer := range req.Signers { + if !wf.HasRole(signer.Role) { + writeError(w, http.StatusBadRequest, "invalid_role", + "Role '"+signer.Role+"' is not defined in workflow '"+req.Workflow+"'") + return + } + } + + // Validate all required gates have at least one signer + signerRoles := make(map[string]bool) + for _, signer := range req.Signers { + signerRoles[signer.Role] = true + } + for _, gate := range wf.RequiredGates() { + if !signerRoles[gate.Role] { + writeError(w, http.StatusBadRequest, "missing_required_signer", + "Required role '"+gate.Role+"' ("+gate.Label+") has no assigned signer") + return + } + } + + username := "" + if user := auth.UserFromContext(ctx); user != nil { + username = user.Username + } + + var ecoNumber *string + if req.ECONumber != "" { + ecoNumber = &req.ECONumber + } + + approval := &db.ItemApproval{ + ItemID: item.ID, + WorkflowName: req.Workflow, + ECONumber: ecoNumber, + State: "pending", + UpdatedBy: &username, + } + + if err := s.approvals.Create(ctx, approval); err != nil { + s.logger.Error().Err(err).Msg("failed to create approval") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create approval") + return + } + + // Add signature rows for each signer + for _, signer := range req.Signers { + sig := &db.ApprovalSignature{ + ApprovalID: approval.ID, + Username: signer.Username, + Role: signer.Role, + Status: "pending", + } + if err := s.approvals.AddSignature(ctx, sig); err != nil { + s.logger.Error().Err(err).Msg("failed to add signature") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add signer") + return + } + } + + // Re-fetch with signatures for response + approval, err = s.approvals.GetWithSignatures(ctx, approval.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get approval") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval") + return + } + + resp := approvalToResponse(approval) + writeJSON(w, http.StatusCreated, resp) + s.broker.Publish("approval.created", mustMarshal(map[string]any{ + "part_number": partNumber, + "approval_id": approval.ID, + "workflow": approval.WorkflowName, + "eco_number": approval.ECONumber, + })) +} + +// HandleSignApproval records an approve or reject signature. +// POST /api/items/{partNumber}/approvals/{id}/sign +func (s *Server) HandleSignApproval(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + approvalID := chi.URLParam(r, "id") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + approval, err := s.approvals.GetWithSignatures(ctx, approvalID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get approval") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval") + return + } + if approval == nil || approval.ItemID != item.ID { + writeError(w, http.StatusNotFound, "not_found", "Approval not found") + return + } + + if approval.State != "pending" { + writeError(w, http.StatusUnprocessableEntity, "invalid_state", + "Approval is in state '"+approval.State+"', signatures can only be added when 'pending'") + return + } + + var req SignApprovalRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + + if req.Status != "approved" && req.Status != "rejected" { + writeError(w, http.StatusBadRequest, "invalid_status", "Status must be 'approved' or 'rejected'") + return + } + + // Get the caller's username + username := "" + if user := auth.UserFromContext(ctx); user != nil { + username = user.Username + } + + // Check that the caller has a pending signature on this approval + sig, err := s.approvals.GetSignatureForUser(ctx, approvalID, username) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get signature") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check signature") + return + } + if sig == nil { + writeError(w, http.StatusForbidden, "not_a_signer", "You are not a signer on this approval") + return + } + if sig.Status != "pending" { + writeError(w, http.StatusConflict, "already_signed", "You have already signed this approval") + return + } + + // Update the signature + if err := s.approvals.UpdateSignature(ctx, sig.ID, req.Status, req.Comment); err != nil { + s.logger.Error().Err(err).Msg("failed to update signature") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update signature") + return + } + + s.broker.Publish("approval.signed", mustMarshal(map[string]any{ + "part_number": partNumber, + "approval_id": approvalID, + "username": username, + "status": req.Status, + })) + + // Evaluate auto-advance based on workflow rules + wf := s.workflows[approval.WorkflowName] + if wf != nil { + // Re-fetch signatures after update + approval, err = s.approvals.GetWithSignatures(ctx, approvalID) + if err == nil && approval != nil { + newState := evaluateApprovalState(wf, approval) + if newState != "" && newState != approval.State { + if err := s.approvals.UpdateState(ctx, approvalID, newState, username); err != nil { + s.logger.Warn().Err(err).Msg("failed to auto-advance approval state") + } else { + approval.State = newState + s.broker.Publish("approval.completed", mustMarshal(map[string]any{ + "part_number": partNumber, + "approval_id": approvalID, + "state": newState, + })) + } + } + } + } + + // Return updated approval + if approval == nil { + approval, _ = s.approvals.GetWithSignatures(ctx, approvalID) + } + if approval != nil { + writeJSON(w, http.StatusOK, approvalToResponse(approval)) + } else { + w.WriteHeader(http.StatusOK) + } +} + +// HandleListWorkflows returns all loaded workflow definitions. +// GET /api/workflows +func (s *Server) HandleListWorkflows(w http.ResponseWriter, r *http.Request) { + resp := make([]map[string]any, 0, len(s.workflows)) + for _, wf := range s.workflows { + resp = append(resp, map[string]any{ + "name": wf.Name, + "version": wf.Version, + "description": wf.Description, + "gates": wf.Gates, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +// evaluateApprovalState checks workflow rules against current signatures +// and returns the new state, or "" if no transition is needed. +func evaluateApprovalState(wf *workflow.Workflow, approval *db.ItemApproval) string { + // Check for any rejection + if wf.Rules.AnyReject != "" { + for _, sig := range approval.Signatures { + if sig.Status == "rejected" { + return wf.Rules.AnyReject + } + } + } + + // Check if all required roles have approved + if wf.Rules.AllRequiredApprove != "" { + requiredRoles := make(map[string]bool) + for _, gate := range wf.RequiredGates() { + requiredRoles[gate.Role] = true + } + + // For each required role, check that all signers with that role have approved + for _, sig := range approval.Signatures { + if requiredRoles[sig.Role] && sig.Status != "approved" { + return "" // at least one required signer hasn't approved yet + } + } + + // All required signers approved + return wf.Rules.AllRequiredApprove + } + + return "" +} + +// readJSON decodes a JSON request body. +func readJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 69dc0a4..c123666 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -25,6 +25,7 @@ import ( "github.com/kindredsystems/silo/internal/partnum" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/storage" + "github.com/kindredsystems/silo/internal/workflow" "github.com/rs/zerolog" "gopkg.in/yaml.v3" ) @@ -58,6 +59,8 @@ type Server struct { metadata *db.ItemMetadataRepository deps *db.ItemDependencyRepository macros *db.ItemMacroRepository + approvals *db.ItemApprovalRepository + workflows map[string]*workflow.Workflow } // NewServer creates a new API server. @@ -77,6 +80,7 @@ func NewServer( jobDefsDir string, registry *modules.Registry, cfg *config.Config, + workflows map[string]*workflow.Workflow, ) *Server { items := db.NewItemRepository(database) projects := db.NewProjectRepository(database) @@ -89,6 +93,7 @@ func NewServer( metadata := db.NewItemMetadataRepository(database) itemDeps := db.NewItemDependencyRepository(database) itemMacros := db.NewItemMacroRepository(database) + itemApprovals := db.NewItemApprovalRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -120,6 +125,8 @@ func NewServer( metadata: metadata, deps: itemDeps, macros: itemMacros, + approvals: itemApprovals, + workflows: workflows, } } diff --git a/internal/api/pack_handlers.go b/internal/api/pack_handlers.go index 0220f11..7902d4e 100644 --- a/internal/api/pack_handlers.go +++ b/internal/api/pack_handlers.go @@ -74,11 +74,58 @@ func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev deps = []kc.Dependency{} } + // Build approvals from item_approvals table. + var approvals []kc.ApprovalEntry + dbApprovals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID) + if err != nil { + s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query approvals for packing") + } else { + approvals = make([]kc.ApprovalEntry, len(dbApprovals)) + for i, a := range dbApprovals { + sigs := make([]kc.SignatureEntry, len(a.Signatures)) + for j, sig := range a.Signatures { + var signedAt string + if sig.SignedAt != nil { + signedAt = sig.SignedAt.UTC().Format("2006-01-02T15:04:05Z") + } + var comment string + if sig.Comment != nil { + comment = *sig.Comment + } + sigs[j] = kc.SignatureEntry{ + Username: sig.Username, + Role: sig.Role, + Status: sig.Status, + SignedAt: signedAt, + Comment: comment, + } + } + var ecoNumber string + if a.ECONumber != nil { + ecoNumber = *a.ECONumber + } + var updatedBy string + if a.UpdatedBy != nil { + updatedBy = *a.UpdatedBy + } + approvals[i] = kc.ApprovalEntry{ + ID: a.ID, + WorkflowName: a.WorkflowName, + ECONumber: ecoNumber, + State: a.State, + UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedBy: updatedBy, + Signatures: sigs, + } + } + } + input := &kc.PackInput{ Manifest: manifest, Metadata: metadata, History: history, Dependencies: deps, + Approvals: approvals, } return kc.Pack(data, input) diff --git a/internal/api/routes.go b/internal/api/routes.go index 8cd96fb..677114a 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -68,6 +68,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { // SSE event stream (viewer+) r.Get("/events", server.HandleEvents) + // Workflows (viewer+) + r.Get("/workflows", server.HandleListWorkflows) + // Auth endpoints r.Get("/auth/me", server.HandleGetCurrentUser) r.Route("/auth/tokens", func(r chi.Router) { @@ -177,6 +180,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/dependencies/resolve", server.HandleResolveDependencies) r.Get("/macros", server.HandleGetMacros) r.Get("/macros/{filename}", server.HandleGetMacro) + r.Get("/approvals", server.HandleGetApprovals) // DAG (gated by dag module) r.Route("/dag", func(r chi.Router) { @@ -217,6 +221,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Put("/metadata", server.HandleUpdateMetadata) r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle) r.Patch("/metadata/tags", server.HandleUpdateTags) + r.Post("/approvals", server.HandleCreateApproval) + r.Post("/approvals/{id}/sign", server.HandleSignApproval) }) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index 1d93afd..f5d1390 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,15 +10,16 @@ import ( // Config holds all application configuration. type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - Storage StorageConfig `yaml:"storage"` - Schemas SchemasConfig `yaml:"schemas"` - FreeCAD FreeCADConfig `yaml:"freecad"` - Odoo OdooConfig `yaml:"odoo"` - Auth AuthConfig `yaml:"auth"` - Jobs JobsConfig `yaml:"jobs"` - Modules ModulesConfig `yaml:"modules"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Storage StorageConfig `yaml:"storage"` + Schemas SchemasConfig `yaml:"schemas"` + FreeCAD FreeCADConfig `yaml:"freecad"` + Odoo OdooConfig `yaml:"odoo"` + Auth AuthConfig `yaml:"auth"` + Jobs JobsConfig `yaml:"jobs"` + Workflows WorkflowsConfig `yaml:"workflows"` + Modules ModulesConfig `yaml:"modules"` } // ModulesConfig holds explicit enable/disable toggles for optional modules. @@ -146,6 +147,11 @@ type JobsConfig struct { DefaultPriority int `yaml:"default_priority"` // default 100 } +// WorkflowsConfig holds approval workflow definition settings. +type WorkflowsConfig struct { + Directory string `yaml:"directory"` // default /etc/silo/workflows +} + // OdooConfig holds Odoo ERP integration settings. type OdooConfig struct { Enabled bool `yaml:"enabled"` @@ -204,6 +210,9 @@ func Load(path string) (*Config, error) { if cfg.Jobs.DefaultPriority == 0 { cfg.Jobs.DefaultPriority = 100 } + if cfg.Workflows.Directory == "" { + cfg.Workflows.Directory = "/etc/silo/workflows" + } // Override with environment variables if v := os.Getenv("SILO_DB_HOST"); v != "" { diff --git a/internal/db/item_approvals.go b/internal/db/item_approvals.go new file mode 100644 index 0000000..5923b3e --- /dev/null +++ b/internal/db/item_approvals.go @@ -0,0 +1,212 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ItemApproval represents a row in the item_approvals table. +type ItemApproval struct { + ID string + ItemID string + WorkflowName string + ECONumber *string + State string // draft | pending | approved | rejected + UpdatedAt time.Time + UpdatedBy *string + Signatures []ApprovalSignature // populated by WithSignatures methods +} + +// ApprovalSignature represents a row in the approval_signatures table. +type ApprovalSignature struct { + ID string + ApprovalID string + Username string + Role string + Status string // pending | approved | rejected + SignedAt *time.Time + Comment *string +} + +// ItemApprovalRepository provides item_approvals database operations. +type ItemApprovalRepository struct { + db *DB +} + +// NewItemApprovalRepository creates a new item approval repository. +func NewItemApprovalRepository(db *DB) *ItemApprovalRepository { + return &ItemApprovalRepository{db: db} +} + +// Create inserts a new approval row. The ID is populated on return. +func (r *ItemApprovalRepository) Create(ctx context.Context, a *ItemApproval) error { + return r.db.pool.QueryRow(ctx, ` + INSERT INTO item_approvals (item_id, workflow_name, eco_number, state, updated_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, updated_at + `, a.ItemID, a.WorkflowName, a.ECONumber, a.State, a.UpdatedBy).Scan(&a.ID, &a.UpdatedAt) +} + +// AddSignature inserts a new signature row. The ID is populated on return. +func (r *ItemApprovalRepository) AddSignature(ctx context.Context, s *ApprovalSignature) error { + return r.db.pool.QueryRow(ctx, ` + INSERT INTO approval_signatures (approval_id, username, role, status) + VALUES ($1, $2, $3, $4) + RETURNING id + `, s.ApprovalID, s.Username, s.Role, s.Status).Scan(&s.ID) +} + +// GetWithSignatures returns a single approval with its signatures. +func (r *ItemApprovalRepository) GetWithSignatures(ctx context.Context, approvalID string) (*ItemApproval, error) { + a := &ItemApproval{} + err := r.db.pool.QueryRow(ctx, ` + SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by + FROM item_approvals + WHERE id = $1 + `, approvalID).Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("getting approval: %w", err) + } + + sigs, err := r.signaturesForApproval(ctx, approvalID) + if err != nil { + return nil, err + } + a.Signatures = sigs + return a, nil +} + +// ListByItemWithSignatures returns all approvals for an item, each with signatures. +func (r *ItemApprovalRepository) ListByItemWithSignatures(ctx context.Context, itemID string) ([]*ItemApproval, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by + FROM item_approvals + WHERE item_id = $1 + ORDER BY updated_at DESC + `, itemID) + if err != nil { + return nil, fmt.Errorf("listing approvals: %w", err) + } + defer rows.Close() + + var approvals []*ItemApproval + var approvalIDs []string + for rows.Next() { + a := &ItemApproval{} + if err := rows.Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy); err != nil { + return nil, fmt.Errorf("scanning approval: %w", err) + } + approvals = append(approvals, a) + approvalIDs = append(approvalIDs, a.ID) + } + + if len(approvalIDs) == 0 { + return approvals, nil + } + + // Batch-fetch all signatures + sigRows, err := r.db.pool.Query(ctx, ` + SELECT id, approval_id, username, role, status, signed_at, comment + FROM approval_signatures + WHERE approval_id = ANY($1) + ORDER BY username + `, approvalIDs) + if err != nil { + return nil, fmt.Errorf("listing signatures: %w", err) + } + defer sigRows.Close() + + sigMap := make(map[string][]ApprovalSignature) + for sigRows.Next() { + var s ApprovalSignature + if err := sigRows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil { + return nil, fmt.Errorf("scanning signature: %w", err) + } + sigMap[s.ApprovalID] = append(sigMap[s.ApprovalID], s) + } + + for _, a := range approvals { + a.Signatures = sigMap[a.ID] + if a.Signatures == nil { + a.Signatures = []ApprovalSignature{} + } + } + + return approvals, nil +} + +// UpdateState updates the approval state and updated_by. +func (r *ItemApprovalRepository) UpdateState(ctx context.Context, approvalID, state, updatedBy string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE item_approvals + SET state = $2, updated_by = $3, updated_at = now() + WHERE id = $1 + `, approvalID, state, updatedBy) + if err != nil { + return fmt.Errorf("updating approval state: %w", err) + } + return nil +} + +// GetSignatureForUser returns the signature for a specific user on an approval. +func (r *ItemApprovalRepository) GetSignatureForUser(ctx context.Context, approvalID, username string) (*ApprovalSignature, error) { + s := &ApprovalSignature{} + err := r.db.pool.QueryRow(ctx, ` + SELECT id, approval_id, username, role, status, signed_at, comment + FROM approval_signatures + WHERE approval_id = $1 AND username = $2 + `, approvalID, username).Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("getting signature: %w", err) + } + return s, nil +} + +// UpdateSignature updates a signature's status, comment, and signed_at timestamp. +func (r *ItemApprovalRepository) UpdateSignature(ctx context.Context, sigID, status string, comment *string) error { + _, err := r.db.pool.Exec(ctx, ` + UPDATE approval_signatures + SET status = $2, comment = $3, signed_at = now() + WHERE id = $1 + `, sigID, status, comment) + if err != nil { + return fmt.Errorf("updating signature: %w", err) + } + return nil +} + +// signaturesForApproval returns all signatures for a single approval. +func (r *ItemApprovalRepository) signaturesForApproval(ctx context.Context, approvalID string) ([]ApprovalSignature, error) { + rows, err := r.db.pool.Query(ctx, ` + SELECT id, approval_id, username, role, status, signed_at, comment + FROM approval_signatures + WHERE approval_id = $1 + ORDER BY username + `, approvalID) + if err != nil { + return nil, fmt.Errorf("listing signatures: %w", err) + } + defer rows.Close() + + var sigs []ApprovalSignature + for rows.Next() { + var s ApprovalSignature + if err := rows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil { + return nil, fmt.Errorf("scanning signature: %w", err) + } + sigs = append(sigs, s) + } + if sigs == nil { + sigs = []ApprovalSignature{} + } + return sigs, nil +} diff --git a/internal/kc/kc.go b/internal/kc/kc.go index c10a38e..9954f73 100644 --- a/internal/kc/kc.go +++ b/internal/kc/kc.go @@ -64,6 +64,26 @@ type HistoryEntry struct { Labels []string `json:"labels"` } +// ApprovalEntry represents one entry in silo/approvals.json. +type ApprovalEntry struct { + ID string `json:"id"` + WorkflowName string `json:"workflow"` + ECONumber string `json:"eco_number,omitempty"` + State string `json:"state"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by,omitempty"` + Signatures []SignatureEntry `json:"signatures"` +} + +// SignatureEntry represents one signer in an approval. +type SignatureEntry struct { + Username string `json:"username"` + Role string `json:"role"` + Status string `json:"status"` + SignedAt string `json:"signed_at,omitempty"` + Comment string `json:"comment,omitempty"` +} + // PackInput holds all the data needed to repack silo/ entries in a .kc file. // Each field is optional — nil/empty means the entry is omitted from the ZIP. type PackInput struct { @@ -71,6 +91,7 @@ type PackInput struct { Metadata *Metadata History []HistoryEntry Dependencies []Dependency + Approvals []ApprovalEntry } // Extract opens a ZIP archive from data and parses the silo/ directory. diff --git a/internal/kc/pack.go b/internal/kc/pack.go index 33c2ae0..7844afd 100644 --- a/internal/kc/pack.go +++ b/internal/kc/pack.go @@ -83,6 +83,11 @@ func Pack(original []byte, input *PackInput) ([]byte, error) { return nil, fmt.Errorf("kc: writing dependencies.json: %w", err) } } + if input.Approvals != nil { + if err := writeJSONEntry(zw, "silo/approvals.json", input.Approvals); err != nil { + return nil, fmt.Errorf("kc: writing approvals.json: %w", err) + } + } if err := zw.Close(); err != nil { return nil, fmt.Errorf("kc: closing zip writer: %w", err) diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go new file mode 100644 index 0000000..971c219 --- /dev/null +++ b/internal/workflow/workflow.go @@ -0,0 +1,156 @@ +// Package workflow handles YAML approval workflow definition parsing and validation. +package workflow + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Workflow represents an approval workflow definition loaded from YAML. +type Workflow struct { + Name string `yaml:"name" json:"name"` + Version int `yaml:"version" json:"version"` + Description string `yaml:"description" json:"description"` + States []string `yaml:"states" json:"states"` + Gates []Gate `yaml:"gates" json:"gates"` + Rules Rules `yaml:"rules" json:"rules"` +} + +// Gate defines a required or optional signature role in a workflow. +type Gate struct { + Role string `yaml:"role" json:"role"` + Label string `yaml:"label" json:"label"` + Required bool `yaml:"required" json:"required"` +} + +// Rules defines how signatures determine state transitions. +type Rules struct { + AnyReject string `yaml:"any_reject" json:"any_reject"` + AllRequiredApprove string `yaml:"all_required_approve" json:"all_required_approve"` +} + +// WorkflowFile wraps a workflow for YAML parsing. +type WorkflowFile struct { + Workflow Workflow `yaml:"workflow"` +} + +var requiredStates = []string{"draft", "pending", "approved", "rejected"} + +// Load reads a workflow definition from a YAML file. +func Load(path string) (*Workflow, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading workflow file: %w", err) + } + + var wf WorkflowFile + if err := yaml.Unmarshal(data, &wf); err != nil { + return nil, fmt.Errorf("parsing workflow YAML: %w", err) + } + + w := &wf.Workflow + + if w.Version <= 0 { + w.Version = 1 + } + + if err := w.Validate(); err != nil { + return nil, fmt.Errorf("validating %s: %w", path, err) + } + + return w, nil +} + +// LoadAll reads all workflow definitions from a directory. +func LoadAll(dir string) (map[string]*Workflow, error) { + workflows := make(map[string]*Workflow) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading workflows directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") { + continue + } + + path := filepath.Join(dir, entry.Name()) + w, err := Load(path) + if err != nil { + return nil, fmt.Errorf("loading %s: %w", entry.Name(), err) + } + workflows[w.Name] = w + } + + return workflows, nil +} + +// Validate checks that the workflow definition is well-formed. +func (w *Workflow) Validate() error { + if w.Name == "" { + return fmt.Errorf("workflow name is required") + } + + // Validate states include all required states + stateSet := make(map[string]bool, len(w.States)) + for _, s := range w.States { + stateSet[s] = true + } + for _, rs := range requiredStates { + if !stateSet[rs] { + return fmt.Errorf("workflow must include state %q", rs) + } + } + + // Validate gates + if len(w.Gates) == 0 { + return fmt.Errorf("workflow must have at least one gate") + } + for i, g := range w.Gates { + if g.Role == "" { + return fmt.Errorf("gate %d: role is required", i) + } + if g.Label == "" { + return fmt.Errorf("gate %d: label is required", i) + } + } + + // Validate rules reference valid states + if w.Rules.AnyReject != "" && !stateSet[w.Rules.AnyReject] { + return fmt.Errorf("rules.any_reject references unknown state %q", w.Rules.AnyReject) + } + if w.Rules.AllRequiredApprove != "" && !stateSet[w.Rules.AllRequiredApprove] { + return fmt.Errorf("rules.all_required_approve references unknown state %q", w.Rules.AllRequiredApprove) + } + + return nil +} + +// RequiredGates returns only the gates where Required is true. +func (w *Workflow) RequiredGates() []Gate { + var gates []Gate + for _, g := range w.Gates { + if g.Required { + gates = append(gates, g) + } + } + return gates +} + +// HasRole returns true if the workflow defines a gate with the given role. +func (w *Workflow) HasRole(role string) bool { + for _, g := range w.Gates { + if g.Role == role { + return true + } + } + return false +} diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go new file mode 100644 index 0000000..671aeeb --- /dev/null +++ b/internal/workflow/workflow_test.go @@ -0,0 +1,167 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_Valid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + os.WriteFile(path, []byte(` +workflow: + name: test-wf + version: 1 + description: "Test workflow" + states: [draft, pending, approved, rejected] + gates: + - role: reviewer + label: "Review" + required: true + rules: + any_reject: rejected + all_required_approve: approved +`), 0644) + + w, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if w.Name != "test-wf" { + t.Errorf("Name = %q, want %q", w.Name, "test-wf") + } + if w.Version != 1 { + t.Errorf("Version = %d, want 1", w.Version) + } + if len(w.Gates) != 1 { + t.Fatalf("Gates count = %d, want 1", len(w.Gates)) + } + if w.Gates[0].Role != "reviewer" { + t.Errorf("Gates[0].Role = %q, want %q", w.Gates[0].Role, "reviewer") + } +} + +func TestLoad_MissingState(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + os.WriteFile(path, []byte(` +workflow: + name: bad + states: [draft, pending] + gates: + - role: r + label: "R" + required: true +`), 0644) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for missing required states") + } +} + +func TestLoad_NoGates(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "no-gates.yaml") + os.WriteFile(path, []byte(` +workflow: + name: no-gates + states: [draft, pending, approved, rejected] + gates: [] +`), 0644) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for no gates") + } +} + +func TestLoadAll(t *testing.T) { + dir := t.TempDir() + + os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(` +workflow: + name: alpha + states: [draft, pending, approved, rejected] + gates: + - role: r + label: "R" + required: true + rules: + any_reject: rejected + all_required_approve: approved +`), 0644) + + os.WriteFile(filepath.Join(dir, "b.yml"), []byte(` +workflow: + name: beta + states: [draft, pending, approved, rejected] + gates: + - role: r + label: "R" + required: true + rules: + any_reject: rejected +`), 0644) + + // Non-yaml file should be ignored + os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore me"), 0644) + + wfs, err := LoadAll(dir) + if err != nil { + t.Fatalf("LoadAll() error: %v", err) + } + if len(wfs) != 2 { + t.Fatalf("LoadAll() count = %d, want 2", len(wfs)) + } + if wfs["alpha"] == nil { + t.Error("missing workflow 'alpha'") + } + if wfs["beta"] == nil { + t.Error("missing workflow 'beta'") + } +} + +func TestRequiredGates(t *testing.T) { + w := &Workflow{ + Gates: []Gate{ + {Role: "engineer", Label: "Eng", Required: true}, + {Role: "quality", Label: "QA", Required: false}, + {Role: "manager", Label: "Mgr", Required: true}, + }, + } + rg := w.RequiredGates() + if len(rg) != 2 { + t.Fatalf("RequiredGates() count = %d, want 2", len(rg)) + } + if rg[0].Role != "engineer" || rg[1].Role != "manager" { + t.Errorf("RequiredGates() roles = %v, want [engineer, manager]", rg) + } +} + +func TestHasRole(t *testing.T) { + w := &Workflow{ + Gates: []Gate{ + {Role: "engineer", Label: "Eng", Required: true}, + }, + } + if !w.HasRole("engineer") { + t.Error("HasRole(engineer) = false, want true") + } + if w.HasRole("manager") { + t.Error("HasRole(manager) = true, want false") + } +} + +func TestValidate_InvalidRuleState(t *testing.T) { + w := &Workflow{ + Name: "bad-rule", + States: []string{"draft", "pending", "approved", "rejected"}, + Gates: []Gate{{Role: "r", Label: "R", Required: true}}, + Rules: Rules{AnyReject: "nonexistent"}, + } + if err := w.Validate(); err == nil { + t.Fatal("expected error for invalid rule state reference") + } +} diff --git a/migrations/019_approval_workflow_name.sql b/migrations/019_approval_workflow_name.sql new file mode 100644 index 0000000..e7d9719 --- /dev/null +++ b/migrations/019_approval_workflow_name.sql @@ -0,0 +1,2 @@ +-- Add workflow_name column to item_approvals for YAML-configurable approval workflows. +ALTER TABLE item_approvals ADD COLUMN workflow_name TEXT NOT NULL DEFAULT 'default'; diff --git a/workflows/engineering-change.yaml b/workflows/engineering-change.yaml new file mode 100644 index 0000000..03c79c2 --- /dev/null +++ b/workflows/engineering-change.yaml @@ -0,0 +1,25 @@ +workflow: + name: engineering-change + version: 1 + description: "Standard engineering change order with peer review and manager approval" + + states: + - draft + - pending + - approved + - rejected + + gates: + - role: engineer + label: "Peer Review" + required: true + - role: manager + label: "Manager Approval" + required: true + - role: quality + label: "Quality Sign-off" + required: false + + rules: + any_reject: rejected + all_required_approve: approved diff --git a/workflows/quick-review.yaml b/workflows/quick-review.yaml new file mode 100644 index 0000000..a0974b3 --- /dev/null +++ b/workflows/quick-review.yaml @@ -0,0 +1,19 @@ +workflow: + name: quick-review + version: 1 + description: "Single reviewer approval for minor changes" + + states: + - draft + - pending + - approved + - rejected + + gates: + - role: reviewer + label: "Review" + required: true + + rules: + any_reject: rejected + all_required_approve: approved