bootstrap: iniciar bitácora operativa
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const siteStateVersion = 2
|
||||
|
||||
func loadReviewLog(root string) (ReviewLog, error) {
|
||||
log := ReviewLog{Version: 1, Events: []ReviewEvent{}}
|
||||
files, err := reviewEventFiles(root)
|
||||
if err != nil {
|
||||
return log, err
|
||||
}
|
||||
for _, path := range files {
|
||||
var event ReviewEvent
|
||||
if err := loadJSON(path, &event); err != nil {
|
||||
return log, err
|
||||
}
|
||||
log.Events = append(log.Events, event)
|
||||
}
|
||||
sortReviewEvents(log.Events)
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func loadTaskLog(root string) (TaskLog, error) {
|
||||
log := TaskLog{Version: 1, Events: []TaskEvent{}}
|
||||
files, err := taskEventFiles(root)
|
||||
if err != nil {
|
||||
return log, err
|
||||
}
|
||||
for _, path := range files {
|
||||
var event TaskEvent
|
||||
if err := loadJSON(path, &event); err != nil {
|
||||
return log, err
|
||||
}
|
||||
log.Events = append(log.Events, event)
|
||||
}
|
||||
sortTaskEvents(log.Events)
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func requiredReviewers(registry AdminRegistry, policy, authorID string) []string {
|
||||
policy = firstNonEmpty(policy, registry.ReviewPolicy.Default)
|
||||
required := activeAdminIDs(registry)
|
||||
excludeAuthor := policy == "all-active-except-author" ||
|
||||
(policy == "all-active" && !registry.ReviewPolicy.IncludeAuthor)
|
||||
if !excludeAuthor || authorID == "" {
|
||||
return required
|
||||
}
|
||||
filtered := make([]string, 0, len(required))
|
||||
for _, id := range required {
|
||||
if id != authorID {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (a *App) BuildState(root string) (SiteState, error) {
|
||||
registry, err := loadAdmins(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
reviews, err := loadReviewLog(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
tasksLog, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
memoryFiles, err := listMarkdownFiles(root, "memorias")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
taskFiles, err := listMarkdownFiles(root, "tareas")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
projectFiles, err := listMarkdownFiles(root, "proyectos")
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
reviewFiles, err := reviewEventFiles(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
taskEventPaths, err := taskEventFiles(root)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
sourcePaths := []string{filepath.Join(root, "data", "admins.json")}
|
||||
sourcePaths = append(sourcePaths, reviewFiles...)
|
||||
sourcePaths = append(sourcePaths, taskEventPaths...)
|
||||
sourcePaths = append(sourcePaths, memoryFiles...)
|
||||
sourcePaths = append(sourcePaths, taskFiles...)
|
||||
sourcePaths = append(sourcePaths, projectFiles...)
|
||||
digest, err := sourceDigest(sourcePaths)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
|
||||
memories := make([]MemoryRecord, 0, len(memoryFiles))
|
||||
for _, path := range memoryFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
record := MemoryRecord{
|
||||
EntryID: id,
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Date: meta.String("date"),
|
||||
AuthorID: meta.ExtraString("author_id"),
|
||||
AuthorFingerprint: meta.ExtraString("author_fingerprint"),
|
||||
Risk: firstNonEmpty(meta.ExtraString("risk"), "low"),
|
||||
Ticket: meta.ExtraString("ticket"),
|
||||
ReviewRequired: meta.ExtraBool("review_required"),
|
||||
ReviewPolicy: meta.ExtraString("review_policy"),
|
||||
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"),
|
||||
Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
|
||||
Tags: uniqueSorted(meta.Taxonomies["tags"]),
|
||||
Systems: uniqueSorted(meta.Taxonomies["systems"]),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
ContentSHA256: meta.ContentHash,
|
||||
}
|
||||
record.Permalink = "/memorias/" + record.Slug + "/"
|
||||
if record.ReviewPolicy == "" {
|
||||
record.ReviewPolicy = registry.ReviewPolicy.Default
|
||||
}
|
||||
memories = append(memories, record)
|
||||
}
|
||||
sort.Slice(memories, func(i, j int) bool {
|
||||
if memories[i].Date == memories[j].Date {
|
||||
return memories[i].EntryID > memories[j].EntryID
|
||||
}
|
||||
return memories[i].Date > memories[j].Date
|
||||
})
|
||||
|
||||
activeIDs := activeAdminIDs(registry)
|
||||
queue := []ReviewQueueItem{}
|
||||
for _, memory := range memories {
|
||||
if !memory.ReviewRequired {
|
||||
continue
|
||||
}
|
||||
var required []string
|
||||
if memory.ReviewerSnapshot {
|
||||
required = append([]string(nil), memory.Reviewers...)
|
||||
} else {
|
||||
required = requiredReviewers(registry, memory.ReviewPolicy, memory.AuthorID)
|
||||
}
|
||||
|
||||
reviewedSet := map[string]bool{}
|
||||
for _, event := range reviews.Events {
|
||||
if normalizeID(event.EntryID) != memory.EntryID || event.ContentSHA256 != memory.ContentSHA256 {
|
||||
continue
|
||||
}
|
||||
if contains(required, event.AdminID) {
|
||||
reviewedSet[event.AdminID] = true
|
||||
}
|
||||
}
|
||||
reviewed := []string{}
|
||||
missing := []string{}
|
||||
for _, id := range required {
|
||||
if reviewedSet[id] {
|
||||
reviewed = append(reviewed, id)
|
||||
} else {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
item := ReviewQueueItem{
|
||||
EntryID: memory.EntryID, Title: memory.Title, Description: memory.Description,
|
||||
Date: memory.Date, AuthorID: memory.AuthorID, Risk: memory.Risk,
|
||||
Tags: memory.Tags, Systems: memory.Systems, Permalink: memory.Permalink,
|
||||
ContentSHA256: memory.ContentSHA256, ReviewedBy: reviewed,
|
||||
MissingReviewers: missing, RequiredCount: len(required), ReviewedCount: len(reviewed),
|
||||
}
|
||||
if !memory.ReviewerSnapshot && len(activeIDs) == 0 {
|
||||
item.ConfigurationError = "No hay administradores activos; registre al menos uno antes de cerrar revisiones."
|
||||
}
|
||||
if item.ConfigurationError != "" || len(missing) > 0 {
|
||||
queue = append(queue, item)
|
||||
}
|
||||
}
|
||||
|
||||
taskStates := make([]TaskState, 0, len(taskFiles))
|
||||
taskIndex := map[string]int{}
|
||||
for _, path := range taskFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
state := TaskState{
|
||||
TaskID: id,
|
||||
RunningBy: []string{},
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "todo"),
|
||||
Priority: firstNonEmpty(meta.ExtraString("priority"), "medium"),
|
||||
ProjectID: normalizeID(meta.ExtraString("project_id")),
|
||||
Assignees: uniqueSorted(meta.ExtraStrings("assignees")),
|
||||
EstimateMinutes: meta.ExtraInt("estimate_minutes"),
|
||||
Due: meta.ExtraString("due"),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
state.Permalink = "/tareas/" + state.Slug + "/"
|
||||
taskIndex[id] = len(taskStates)
|
||||
taskStates = append(taskStates, state)
|
||||
}
|
||||
running := map[string]map[string]bool{}
|
||||
for _, event := range tasksLog.Events {
|
||||
id := normalizeID(event.TaskID)
|
||||
idx, ok := taskIndex[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
state := &taskStates[idx]
|
||||
actor := event.AdminID
|
||||
switch event.Type {
|
||||
case "start":
|
||||
if running[id] == nil {
|
||||
running[id] = map[string]bool{}
|
||||
}
|
||||
running[id][actor] = true
|
||||
if state.Status == "todo" || state.Status == "backlog" {
|
||||
state.Status = "in_progress"
|
||||
}
|
||||
case "stop":
|
||||
state.ActualMinutes += event.Minutes
|
||||
if running[id] != nil {
|
||||
delete(running[id], actor)
|
||||
}
|
||||
case "adjust":
|
||||
state.ActualMinutes += event.Minutes
|
||||
case "status":
|
||||
if event.Status != "" {
|
||||
state.Status = event.Status
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, actors := range running {
|
||||
idx, ok := taskIndex[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for actor, isRunning := range actors {
|
||||
if isRunning {
|
||||
taskStates[idx].RunningBy = append(taskStates[idx].RunningBy, actor)
|
||||
}
|
||||
}
|
||||
sort.Strings(taskStates[idx].RunningBy)
|
||||
}
|
||||
sort.Slice(taskStates, func(i, j int) bool {
|
||||
order := map[string]int{"in_progress": 0, "blocked": 1, "todo": 2, "backlog": 3, "done": 4, "cancelled": 5}
|
||||
oi, ok := order[taskStates[i].Status]
|
||||
if !ok {
|
||||
oi = 9
|
||||
}
|
||||
oj, ok := order[taskStates[j].Status]
|
||||
if !ok {
|
||||
oj = 9
|
||||
}
|
||||
if oi == oj {
|
||||
if taskStates[i].Due == taskStates[j].Due {
|
||||
return taskStates[i].TaskID < taskStates[j].TaskID
|
||||
}
|
||||
if taskStates[i].Due == "" {
|
||||
return false
|
||||
}
|
||||
if taskStates[j].Due == "" {
|
||||
return true
|
||||
}
|
||||
return taskStates[i].Due < taskStates[j].Due
|
||||
}
|
||||
return oi < oj
|
||||
})
|
||||
|
||||
projects := make([]ProjectState, 0, len(projectFiles))
|
||||
for _, path := range projectFiles {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return SiteState{}, err
|
||||
}
|
||||
id := normalizeID(meta.ExtraString("id"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
project := ProjectState{
|
||||
ProjectID: id,
|
||||
Title: meta.String("title"),
|
||||
Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "planned"),
|
||||
Owners: uniqueSorted(meta.ExtraStrings("owners")),
|
||||
TargetDate: meta.ExtraString("target_date"),
|
||||
Slug: pageSlug(meta),
|
||||
SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
project.Permalink = "/proyectos/" + project.Slug + "/"
|
||||
projects = append(projects, project)
|
||||
}
|
||||
sort.Slice(projects, func(i, j int) bool {
|
||||
return projects[i].ProjectID < projects[j].ProjectID
|
||||
})
|
||||
|
||||
openTasks := 0
|
||||
for _, task := range taskStates {
|
||||
if task.Status != "done" && task.Status != "cancelled" {
|
||||
openTasks++
|
||||
}
|
||||
}
|
||||
activeProjects := 0
|
||||
for _, project := range projects {
|
||||
switch project.Status {
|
||||
case "done", "completed", "cancelled", "archived":
|
||||
default:
|
||||
activeProjects++
|
||||
}
|
||||
}
|
||||
recent := memories
|
||||
if len(recent) > 30 {
|
||||
recent = recent[:30]
|
||||
}
|
||||
return SiteState{
|
||||
Version: siteStateVersion,
|
||||
SourceSHA256: digest,
|
||||
Stats: SiteStats{
|
||||
ActiveAdmins: len(activeIDs), PendingReviews: len(queue),
|
||||
OpenTasks: openTasks, ActiveProjects: activeProjects,
|
||||
},
|
||||
ReviewQueue: queue,
|
||||
RecentMemories: recent,
|
||||
Tasks: taskStates,
|
||||
Projects: projects,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Sync(root string, check bool) (SiteState, bool, string, error) {
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return SiteState{}, false, "", err
|
||||
}
|
||||
path := filepath.Join(root, "data", "generated", "site_state.json")
|
||||
var old SiteState
|
||||
oldErr := loadJSON(path, &old)
|
||||
upToDate := oldErr == nil && old.Version == state.Version && old.SourceSHA256 == state.SourceSHA256
|
||||
if check {
|
||||
if !upToDate {
|
||||
return state, false, path, NewError(ExitValidation, "generated_state_stale",
|
||||
"data/generated/site_state.json no corresponde a las fuentes actuales", map[string]any{
|
||||
"expected_source_sha256": state.SourceSHA256,
|
||||
"actual_source_sha256": old.SourceSHA256,
|
||||
"hint": "ejecute clusterlog sync",
|
||||
})
|
||||
}
|
||||
return old, false, path, nil
|
||||
}
|
||||
if upToDate {
|
||||
return old, false, path, nil
|
||||
}
|
||||
state.GeneratedAt = a.Options.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
if a.Options.DryRun {
|
||||
return state, true, path, nil
|
||||
}
|
||||
if err := writeJSONAtomic(path, state); err != nil {
|
||||
return SiteState{}, false, path, err
|
||||
}
|
||||
return state, true, path, nil
|
||||
}
|
||||
|
||||
func findMemory(root, id string) (MemoryRecord, PageMeta, error) {
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "memorias")
|
||||
if err != nil {
|
||||
return MemoryRecord{}, PageMeta{}, err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return MemoryRecord{}, PageMeta{}, err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
record := MemoryRecord{
|
||||
EntryID: id,
|
||||
Title: meta.String("title"), Description: meta.String("description"),
|
||||
Date: meta.String("date"), AuthorID: meta.ExtraString("author_id"),
|
||||
AuthorFingerprint: meta.ExtraString("author_fingerprint"), Risk: meta.ExtraString("risk"),
|
||||
ReviewRequired: meta.ExtraBool("review_required"), ReviewPolicy: meta.ExtraString("review_policy"),
|
||||
ReviewerSnapshot: meta.ExtraBool("reviewer_snapshot"), Reviewers: uniqueSorted(meta.ExtraStrings("reviewers")),
|
||||
Tags: meta.Taxonomies["tags"], Systems: meta.Taxonomies["systems"],
|
||||
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel), ContentSHA256: meta.ContentHash,
|
||||
}
|
||||
record.Permalink = "/memorias/" + record.Slug + "/"
|
||||
return record, meta, nil
|
||||
}
|
||||
}
|
||||
return MemoryRecord{}, PageMeta{}, NewError(ExitUsage, "memory_not_found", "no existe la memoria indicada", id)
|
||||
}
|
||||
|
||||
func findTask(root, id string) (TaskState, PageMeta, error) {
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "tareas")
|
||||
if err != nil {
|
||||
return TaskState{}, PageMeta{}, err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return TaskState{}, PageMeta{}, err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
state := TaskState{
|
||||
TaskID: id, Title: meta.String("title"), Description: meta.String("description"),
|
||||
Status: firstNonEmpty(meta.ExtraString("status"), "todo"), Priority: meta.ExtraString("priority"),
|
||||
ProjectID: normalizeID(meta.ExtraString("project_id")), Assignees: meta.ExtraStrings("assignees"),
|
||||
EstimateMinutes: meta.ExtraInt("estimate_minutes"), Due: meta.ExtraString("due"),
|
||||
Slug: pageSlug(meta), SourcePath: filepath.ToSlash(rel),
|
||||
}
|
||||
state.Permalink = "/tareas/" + state.Slug + "/"
|
||||
return state, meta, nil
|
||||
}
|
||||
}
|
||||
return TaskState{}, PageMeta{}, NewError(ExitUsage, "task_not_found", "no existe la tarea indicada", id)
|
||||
}
|
||||
|
||||
func taskActiveStart(log TaskLog, adminID string) (TaskEvent, bool) {
|
||||
active := map[string]TaskEvent{}
|
||||
for _, event := range log.Events {
|
||||
if event.AdminID != adminID {
|
||||
continue
|
||||
}
|
||||
id := normalizeID(event.TaskID)
|
||||
switch event.Type {
|
||||
case "start":
|
||||
active[id] = event
|
||||
case "stop":
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return TaskEvent{}, false
|
||||
}
|
||||
keys := make([]string, 0, len(active))
|
||||
for id := range active {
|
||||
keys = append(keys, id)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return active[keys[0]], true
|
||||
}
|
||||
|
||||
func taskIsRunning(log TaskLog, taskID, adminID string) (TaskEvent, bool) {
|
||||
taskID = normalizeID(taskID)
|
||||
var current TaskEvent
|
||||
running := false
|
||||
for _, event := range log.Events {
|
||||
if normalizeID(event.TaskID) != taskID || event.AdminID != adminID {
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case "start":
|
||||
current = event
|
||||
running = true
|
||||
case "stop":
|
||||
running = false
|
||||
}
|
||||
}
|
||||
return current, running
|
||||
}
|
||||
|
||||
func ensureProjectExists(root, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
}
|
||||
id = normalizeID(id)
|
||||
files, err := listMarkdownFiles(root, "proyectos")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
meta, err := parseMarkdown(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalizeID(meta.ExtraString("id")) == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return NewError(ExitValidation, "project_not_found", "la tarea referencia un proyecto inexistente", id)
|
||||
}
|
||||
|
||||
func generatedStateExists(root string) bool {
|
||||
_, err := os.Stat(filepath.Join(root, "data", "generated", "site_state.json"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func statusIsClosed(status string) bool {
|
||||
switch strings.ToLower(status) {
|
||||
case "done", "completed", "cancelled", "archived":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pageIDDuplicates(records []string) []string {
|
||||
counts := map[string]int{}
|
||||
for _, id := range records {
|
||||
counts[id]++
|
||||
}
|
||||
out := []string{}
|
||||
for id, count := range counts {
|
||||
if id != "" && count > 1 {
|
||||
out = append(out, fmt.Sprintf("%s (%d)", id, count))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user