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
This commit is contained in:
@@ -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)
|
||||
|
||||
391
internal/api/approval_handlers.go
Normal file
391
internal/api/approval_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
212
internal/db/item_approvals.go
Normal file
212
internal/db/item_approvals.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
156
internal/workflow/workflow.go
Normal file
156
internal/workflow/workflow.go
Normal file
@@ -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
|
||||
}
|
||||
167
internal/workflow/workflow_test.go
Normal file
167
internal/workflow/workflow_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
2
migrations/019_approval_workflow_name.sql
Normal file
2
migrations/019_approval_workflow_name.sql
Normal file
@@ -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';
|
||||
25
workflows/engineering-change.yaml
Normal file
25
workflows/engineering-change.yaml
Normal file
@@ -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
|
||||
19
workflows/quick-review.yaml
Normal file
19
workflows/quick-review.yaml
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user