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/modules"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
"github.com/kindredsystems/silo/internal/storage"
|
"github.com/kindredsystems/silo/internal/storage"
|
||||||
|
"github.com/kindredsystems/silo/internal/workflow"
|
||||||
"github.com/rs/zerolog"
|
"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
|
// Initialize module registry
|
||||||
registry := modules.NewRegistry()
|
registry := modules.NewRegistry()
|
||||||
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
|
if err := modules.LoadState(registry, cfg, database.Pool()); err != nil {
|
||||||
@@ -258,7 +272,7 @@ func main() {
|
|||||||
// Create API server
|
// Create API server
|
||||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store,
|
||||||
authService, sessionManager, oidcBackend, &cfg.Auth, broker, serverState,
|
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)
|
router := api.NewRouter(server, logger)
|
||||||
|
|
||||||
// Start background sweepers for job/runner timeouts (only when jobs module enabled)
|
// 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/partnum"
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
"github.com/kindredsystems/silo/internal/storage"
|
"github.com/kindredsystems/silo/internal/storage"
|
||||||
|
"github.com/kindredsystems/silo/internal/workflow"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,8 @@ type Server struct {
|
|||||||
metadata *db.ItemMetadataRepository
|
metadata *db.ItemMetadataRepository
|
||||||
deps *db.ItemDependencyRepository
|
deps *db.ItemDependencyRepository
|
||||||
macros *db.ItemMacroRepository
|
macros *db.ItemMacroRepository
|
||||||
|
approvals *db.ItemApprovalRepository
|
||||||
|
workflows map[string]*workflow.Workflow
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server.
|
// NewServer creates a new API server.
|
||||||
@@ -77,6 +80,7 @@ func NewServer(
|
|||||||
jobDefsDir string,
|
jobDefsDir string,
|
||||||
registry *modules.Registry,
|
registry *modules.Registry,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
workflows map[string]*workflow.Workflow,
|
||||||
) *Server {
|
) *Server {
|
||||||
items := db.NewItemRepository(database)
|
items := db.NewItemRepository(database)
|
||||||
projects := db.NewProjectRepository(database)
|
projects := db.NewProjectRepository(database)
|
||||||
@@ -89,6 +93,7 @@ func NewServer(
|
|||||||
metadata := db.NewItemMetadataRepository(database)
|
metadata := db.NewItemMetadataRepository(database)
|
||||||
itemDeps := db.NewItemDependencyRepository(database)
|
itemDeps := db.NewItemDependencyRepository(database)
|
||||||
itemMacros := db.NewItemMacroRepository(database)
|
itemMacros := db.NewItemMacroRepository(database)
|
||||||
|
itemApprovals := db.NewItemApprovalRepository(database)
|
||||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||||
partgen := partnum.NewGenerator(schemas, seqStore)
|
partgen := partnum.NewGenerator(schemas, seqStore)
|
||||||
|
|
||||||
@@ -120,6 +125,8 @@ func NewServer(
|
|||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
deps: itemDeps,
|
deps: itemDeps,
|
||||||
macros: itemMacros,
|
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{}
|
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{
|
input := &kc.PackInput{
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
History: history,
|
History: history,
|
||||||
Dependencies: deps,
|
Dependencies: deps,
|
||||||
|
Approvals: approvals,
|
||||||
}
|
}
|
||||||
|
|
||||||
return kc.Pack(data, input)
|
return kc.Pack(data, input)
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
// SSE event stream (viewer+)
|
// SSE event stream (viewer+)
|
||||||
r.Get("/events", server.HandleEvents)
|
r.Get("/events", server.HandleEvents)
|
||||||
|
|
||||||
|
// Workflows (viewer+)
|
||||||
|
r.Get("/workflows", server.HandleListWorkflows)
|
||||||
|
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
r.Get("/auth/me", server.HandleGetCurrentUser)
|
r.Get("/auth/me", server.HandleGetCurrentUser)
|
||||||
r.Route("/auth/tokens", func(r chi.Router) {
|
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("/dependencies/resolve", server.HandleResolveDependencies)
|
||||||
r.Get("/macros", server.HandleGetMacros)
|
r.Get("/macros", server.HandleGetMacros)
|
||||||
r.Get("/macros/{filename}", server.HandleGetMacro)
|
r.Get("/macros/{filename}", server.HandleGetMacro)
|
||||||
|
r.Get("/approvals", server.HandleGetApprovals)
|
||||||
|
|
||||||
// DAG (gated by dag module)
|
// DAG (gated by dag module)
|
||||||
r.Route("/dag", func(r chi.Router) {
|
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.Put("/metadata", server.HandleUpdateMetadata)
|
||||||
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle)
|
||||||
r.Patch("/metadata/tags", server.HandleUpdateTags)
|
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.
|
// Config holds all application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
Schemas SchemasConfig `yaml:"schemas"`
|
Schemas SchemasConfig `yaml:"schemas"`
|
||||||
FreeCAD FreeCADConfig `yaml:"freecad"`
|
FreeCAD FreeCADConfig `yaml:"freecad"`
|
||||||
Odoo OdooConfig `yaml:"odoo"`
|
Odoo OdooConfig `yaml:"odoo"`
|
||||||
Auth AuthConfig `yaml:"auth"`
|
Auth AuthConfig `yaml:"auth"`
|
||||||
Jobs JobsConfig `yaml:"jobs"`
|
Jobs JobsConfig `yaml:"jobs"`
|
||||||
Modules ModulesConfig `yaml:"modules"`
|
Workflows WorkflowsConfig `yaml:"workflows"`
|
||||||
|
Modules ModulesConfig `yaml:"modules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModulesConfig holds explicit enable/disable toggles for optional modules.
|
// ModulesConfig holds explicit enable/disable toggles for optional modules.
|
||||||
@@ -146,6 +147,11 @@ type JobsConfig struct {
|
|||||||
DefaultPriority int `yaml:"default_priority"` // default 100
|
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.
|
// OdooConfig holds Odoo ERP integration settings.
|
||||||
type OdooConfig struct {
|
type OdooConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
@@ -204,6 +210,9 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Jobs.DefaultPriority == 0 {
|
if cfg.Jobs.DefaultPriority == 0 {
|
||||||
cfg.Jobs.DefaultPriority = 100
|
cfg.Jobs.DefaultPriority = 100
|
||||||
}
|
}
|
||||||
|
if cfg.Workflows.Directory == "" {
|
||||||
|
cfg.Workflows.Directory = "/etc/silo/workflows"
|
||||||
|
}
|
||||||
|
|
||||||
// Override with environment variables
|
// Override with environment variables
|
||||||
if v := os.Getenv("SILO_DB_HOST"); v != "" {
|
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"`
|
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.
|
// 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.
|
// Each field is optional — nil/empty means the entry is omitted from the ZIP.
|
||||||
type PackInput struct {
|
type PackInput struct {
|
||||||
@@ -71,6 +91,7 @@ type PackInput struct {
|
|||||||
Metadata *Metadata
|
Metadata *Metadata
|
||||||
History []HistoryEntry
|
History []HistoryEntry
|
||||||
Dependencies []Dependency
|
Dependencies []Dependency
|
||||||
|
Approvals []ApprovalEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract opens a ZIP archive from data and parses the silo/ directory.
|
// 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)
|
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 {
|
if err := zw.Close(); err != nil {
|
||||||
return nil, fmt.Errorf("kc: closing zip writer: %w", err)
|
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