- 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
183 lines
4.8 KiB
Go
183 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
"github.com/kindredsystems/silo/internal/kc"
|
|
)
|
|
|
|
// packKCFile gathers DB state and repacks silo/ entries in a .kc file.
|
|
func (s *Server) packKCFile(ctx context.Context, data []byte, item *db.Item, rev *db.Revision, meta *db.ItemMetadata) ([]byte, error) {
|
|
manifest := &kc.Manifest{
|
|
UUID: item.ID,
|
|
KCVersion: derefStr(meta.KCVersion, "1.0"),
|
|
RevisionHash: derefStr(meta.RevisionHash, ""),
|
|
SiloInstance: derefStr(meta.SiloInstance, ""),
|
|
}
|
|
|
|
metadata := &kc.Metadata{
|
|
SchemaName: derefStr(meta.SchemaName, ""),
|
|
Tags: meta.Tags,
|
|
LifecycleState: meta.LifecycleState,
|
|
Fields: meta.Fields,
|
|
}
|
|
|
|
// Build history from last 20 revisions.
|
|
revisions, err := s.items.GetRevisions(ctx, item.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting revisions: %w", err)
|
|
}
|
|
limit := 20
|
|
if len(revisions) < limit {
|
|
limit = len(revisions)
|
|
}
|
|
history := make([]kc.HistoryEntry, limit)
|
|
for i, r := range revisions[:limit] {
|
|
labels := r.Labels
|
|
if labels == nil {
|
|
labels = []string{}
|
|
}
|
|
history[i] = kc.HistoryEntry{
|
|
RevisionNumber: r.RevisionNumber,
|
|
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
|
|
CreatedBy: r.CreatedBy,
|
|
Comment: r.Comment,
|
|
Status: r.Status,
|
|
Labels: labels,
|
|
}
|
|
}
|
|
|
|
// Build dependencies from item_dependencies table.
|
|
var deps []kc.Dependency
|
|
dbDeps, err := s.deps.ListByItem(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to query dependencies for packing")
|
|
} else {
|
|
deps = make([]kc.Dependency, len(dbDeps))
|
|
for i, d := range dbDeps {
|
|
deps[i] = kc.Dependency{
|
|
UUID: d.ChildUUID,
|
|
PartNumber: derefStr(d.ChildPartNumber, ""),
|
|
Revision: derefInt(d.ChildRevision, 0),
|
|
Quantity: derefFloat(d.Quantity, 0),
|
|
Label: derefStr(d.Label, ""),
|
|
Relationship: d.Relationship,
|
|
}
|
|
}
|
|
}
|
|
if deps == nil {
|
|
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)
|
|
}
|
|
|
|
// computeETag generates a quoted ETag from the revision number and metadata freshness.
|
|
func computeETag(rev *db.Revision, meta *db.ItemMetadata) string {
|
|
var ts int64
|
|
if meta != nil {
|
|
ts = meta.UpdatedAt.UnixNano()
|
|
} else {
|
|
ts = rev.CreatedAt.UnixNano()
|
|
}
|
|
raw := fmt.Sprintf("%d:%d", rev.RevisionNumber, ts)
|
|
h := sha256.Sum256([]byte(raw))
|
|
return `"` + hex.EncodeToString(h[:8]) + `"`
|
|
}
|
|
|
|
// canSkipRepack returns true if the stored blob already has up-to-date silo/ data.
|
|
func canSkipRepack(rev *db.Revision, meta *db.ItemMetadata) bool {
|
|
if meta == nil {
|
|
return true // no metadata row = plain .fcstd
|
|
}
|
|
if meta.RevisionHash != nil && rev.FileChecksum != nil &&
|
|
*meta.RevisionHash == *rev.FileChecksum &&
|
|
meta.UpdatedAt.Before(rev.CreatedAt) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// derefStr returns the value of a *string pointer, or fallback if nil.
|
|
func derefStr(p *string, fallback string) string {
|
|
if p != nil {
|
|
return *p
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// derefInt returns the value of a *int pointer, or fallback if nil.
|
|
func derefInt(p *int, fallback int) int {
|
|
if p != nil {
|
|
return *p
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// derefFloat returns the value of a *float64 pointer, or fallback if nil.
|
|
func derefFloat(p *float64, fallback float64) float64 {
|
|
if p != nil {
|
|
return *p
|
|
}
|
|
return fallback
|
|
}
|