bootstrap: iniciar bitácora operativa
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
package clusterlog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) runTask(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
return a.runTaskCreate(root, args[1:])
|
||||
case "start":
|
||||
return a.runTaskStart(root, args[1:])
|
||||
case "stop":
|
||||
return a.runTaskStop(root, args[1:])
|
||||
case "adjust":
|
||||
return a.runTaskAdjust(root, args[1:])
|
||||
case "status":
|
||||
return a.runTaskStatus(root, args[1:])
|
||||
case "done":
|
||||
return a.runTaskDone(root, args[1:])
|
||||
case "list":
|
||||
return a.runTaskList(root, args[1:])
|
||||
default:
|
||||
return NewError(ExitUsage, "task_usage", "uso: task create | start ID | stop ID | adjust ID | status ID | done ID | list", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runTaskCreate(root string, args []string) error {
|
||||
fs := newFlagSet("task create")
|
||||
title := fs.String("title", "", "título")
|
||||
description := fs.String("description", "", "resumen")
|
||||
projectID := fs.String("project", "", "ID de proyecto")
|
||||
priority := fs.String("priority", "", "low|medium|high|urgent")
|
||||
assignees := fs.String("assignees", "", "administradores separados por coma")
|
||||
estimate := fs.String("estimate", "", "minutos, 90m, 2h")
|
||||
due := fs.String("due", "", "fecha YYYY-MM-DD")
|
||||
tags := fs.String("tags", "", "tags separados por coma")
|
||||
systems := fs.String("systems", "", "sistemas separados por coma")
|
||||
objective := fs.String("objective", "", "objetivo")
|
||||
acceptance := fs.String("acceptance", "", "criterios separados por coma")
|
||||
notes := fs.String("notes", "", "notas")
|
||||
bodyFile := fs.String("body-file", "", "Markdown o -")
|
||||
fromJSON := fs.String("from-json", "", "JSON o -")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
var input TaskInput
|
||||
if *fromJSON != "" {
|
||||
if err := decodeJSONInput(*fromJSON, a.Options.Stdin, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if wasSet(fs, "title") {
|
||||
input.Title = *title
|
||||
}
|
||||
if wasSet(fs, "description") {
|
||||
input.Description = *description
|
||||
}
|
||||
if wasSet(fs, "project") {
|
||||
input.ProjectID = *projectID
|
||||
}
|
||||
if wasSet(fs, "priority") {
|
||||
input.Priority = *priority
|
||||
}
|
||||
if wasSet(fs, "assignees") {
|
||||
input.Assignees = splitCSV(*assignees)
|
||||
}
|
||||
if wasSet(fs, "due") {
|
||||
input.Due = *due
|
||||
}
|
||||
if wasSet(fs, "tags") {
|
||||
input.Tags = splitCSV(*tags)
|
||||
}
|
||||
if wasSet(fs, "systems") {
|
||||
input.Systems = splitCSV(*systems)
|
||||
}
|
||||
if wasSet(fs, "objective") {
|
||||
input.Objective = *objective
|
||||
}
|
||||
if wasSet(fs, "acceptance") {
|
||||
input.Acceptance = splitCSV(*acceptance)
|
||||
}
|
||||
if wasSet(fs, "notes") {
|
||||
input.Notes = *notes
|
||||
}
|
||||
if *estimate != "" {
|
||||
minutes, err := parseDurationMinutes(*estimate)
|
||||
if err != nil {
|
||||
return NewError(ExitUsage, "invalid_estimate", err.Error(), nil)
|
||||
}
|
||||
input.EstimateMinutes = minutes
|
||||
}
|
||||
if *bodyFile != "" {
|
||||
data, err := readAll(*bodyFile, a.Options.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.BodyMarkdown = string(data)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, warnings, err := a.createTask(root, identity, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.create", result, warnings...)
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdPath := filepath.FromSlash(result["path"].(string))
|
||||
paths := []string{createdPath}
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", result["task_id"], input.Title), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["commit"] = sha
|
||||
result["open_tasks"] = state.Stats.OpenTasks
|
||||
return a.printResult("task.create", result, warnings...)
|
||||
}
|
||||
|
||||
func (a *App) runTaskStart(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task start ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task start")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentStatus := task.Status
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID == task.TaskID {
|
||||
currentStatus = current.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
if statusIsClosed(currentStatus) {
|
||||
return NewError(ExitConflict, "task_closed", "no se puede iniciar una tarea cerrada", map[string]any{
|
||||
"task_id": task.TaskID, "status": currentStatus,
|
||||
})
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active, ok := taskActiveStart(log, identity.Admin.ID); ok {
|
||||
if normalizeID(active.TaskID) == task.TaskID {
|
||||
return a.printResult("task.start", map[string]any{
|
||||
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
|
||||
"already_running": true, "started_at": active.At,
|
||||
})
|
||||
}
|
||||
return NewError(ExitConflict, "timer_already_running", "el administrador ya tiene otra tarea activa", map[string]any{
|
||||
"active_task_id": active.TaskID,
|
||||
"started_at": active.At,
|
||||
"hint": "ejecute task stop para la tarea activa",
|
||||
})
|
||||
}
|
||||
eventID, err := generateID("EVT", a.Options.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "start", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: a.Options.Now().UTC().Format(time.RFC3339Nano), Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.start", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "start")
|
||||
}
|
||||
|
||||
func (a *App) runTaskStop(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task stop ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task stop")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID)
|
||||
if !running {
|
||||
return NewError(ExitConflict, "timer_not_running", "no hay un temporizador activo para esta tarea y administrador", map[string]any{
|
||||
"task_id": task.TaskID, "admin_id": identity.Admin.ID,
|
||||
})
|
||||
}
|
||||
startedAt, err := time.Parse(time.RFC3339, started.At)
|
||||
if err != nil {
|
||||
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
if now.Before(startedAt) {
|
||||
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
|
||||
}
|
||||
elapsed := now.Sub(startedAt)
|
||||
minutes := int((elapsed + time.Minute - 1) / time.Minute)
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.stop", map[string]any{"event": event, "elapsed": formatMinutes(minutes), "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "stop")
|
||||
}
|
||||
|
||||
func (a *App) runTaskAdjust(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task adjust ID --minutes N [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task adjust")
|
||||
minutes := fs.Int("minutes", 0, "minutos positivos o negativos")
|
||||
note := fs.String("note", "", "justificación del ajuste")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
if !wasSet(fs, "minutes") || *minutes == 0 {
|
||||
return NewError(ExitUsage, "invalid_adjustment", "--minutes debe ser distinto de cero", nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID == task.TaskID && current.ActualMinutes+*minutes < 0 {
|
||||
return NewError(ExitValidation, "negative_actual_time", "el ajuste dejaría el tiempo real por debajo de cero", map[string]any{
|
||||
"task_id": task.TaskID, "actual_minutes": current.ActualMinutes, "adjustment": *minutes,
|
||||
})
|
||||
}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "adjust", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: *minutes, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.adjust", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "adjust")
|
||||
}
|
||||
|
||||
func (a *App) runTaskStatus(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task status ID --status backlog|todo|in_progress|blocked|cancelled [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task status")
|
||||
status := fs.String("status", "", "backlog|todo|in_progress|blocked|cancelled")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
newStatus := strings.ToLower(strings.TrimSpace(*status))
|
||||
allowed := map[string]bool{"backlog": true, "todo": true, "in_progress": true, "blocked": true, "cancelled": true}
|
||||
if !allowed[newStatus] {
|
||||
return NewError(ExitUsage, "invalid_task_status", "use backlog, todo, in_progress, blocked o cancelled; para completar use task done", newStatus)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range state.Tasks {
|
||||
if current.TaskID != task.TaskID {
|
||||
continue
|
||||
}
|
||||
if current.Status == newStatus {
|
||||
return a.printResult("task.status", map[string]any{"task_id": task.TaskID, "status": newStatus, "already_set": true})
|
||||
}
|
||||
if statusIsClosed(newStatus) && len(current.RunningBy) > 0 {
|
||||
return NewError(ExitConflict, "task_has_running_timers", "detenga todos los temporizadores antes de cerrar la tarea", map[string]any{
|
||||
"task_id": task.TaskID, "running_by": current.RunningBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
eventID, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := TaskEvent{
|
||||
ID: eventID, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: newStatus, Note: strings.TrimSpace(*note),
|
||||
}
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.status", map[string]any{"event": event, "dry_run": true})
|
||||
}
|
||||
return a.persistTaskEvent(root, identity, event, "status")
|
||||
}
|
||||
|
||||
func (a *App) runTaskDone(root string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return NewError(ExitUsage, "missing_task_id", "uso: task done ID [--note TEXTO]", nil)
|
||||
}
|
||||
taskID := normalizeID(args[0])
|
||||
fs := newFlagSet("task done")
|
||||
note := fs.String("note", "", "nota breve")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task, _, err := findTask(root, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stateBefore, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range stateBefore.Tasks {
|
||||
if current.TaskID != task.TaskID {
|
||||
continue
|
||||
}
|
||||
if current.Status == "done" {
|
||||
return a.printResult("task.done", map[string]any{"task_id": task.TaskID, "already_done": true})
|
||||
}
|
||||
otherRunners := []string{}
|
||||
for _, adminID := range current.RunningBy {
|
||||
if adminID != identity.Admin.ID {
|
||||
otherRunners = append(otherRunners, adminID)
|
||||
}
|
||||
}
|
||||
if len(otherRunners) > 0 {
|
||||
return NewError(ExitConflict, "task_has_other_running_timers", "otros administradores aún registran tiempo en esta tarea", map[string]any{
|
||||
"task_id": task.TaskID, "running_by": otherRunners,
|
||||
})
|
||||
}
|
||||
}
|
||||
log, err := loadTaskLog(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.Options.Now().UTC()
|
||||
newEvents := []TaskEvent{}
|
||||
if started, running := taskIsRunning(log, task.TaskID, identity.Admin.ID); running {
|
||||
startedAt, parseErr := time.Parse(time.RFC3339, started.At)
|
||||
if parseErr != nil {
|
||||
return NewError(ExitValidation, "invalid_start_event", "el evento start tiene una fecha inválida", started)
|
||||
}
|
||||
if now.Before(startedAt) {
|
||||
return NewError(ExitValidation, "clock_before_start", "la hora actual es anterior al inicio registrado", started)
|
||||
}
|
||||
minutes := int((now.Sub(startedAt) + time.Minute - 1) / time.Minute)
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
id, idErr := generateID("EVT", now)
|
||||
if idErr != nil {
|
||||
return idErr
|
||||
}
|
||||
newEvents = append(newEvents, TaskEvent{
|
||||
ID: id, TaskID: task.TaskID, Type: "stop", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Minutes: minutes,
|
||||
Note: "temporizador cerrado al completar la tarea",
|
||||
})
|
||||
}
|
||||
id, err := generateID("EVT", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statusEvent := TaskEvent{
|
||||
ID: id, TaskID: task.TaskID, Type: "status", AdminID: identity.Admin.ID,
|
||||
Fingerprint: identity.Fingerprint, At: now.Format(time.RFC3339Nano), Status: "done", Note: strings.TrimSpace(*note),
|
||||
}
|
||||
newEvents = append(newEvents, statusEvent)
|
||||
if a.Options.DryRun {
|
||||
return a.printResult("task.done", map[string]any{"events": newEvents, "dry_run": true})
|
||||
}
|
||||
eventPaths, err := writeTaskEvents(root, newEvents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := append([]string(nil), eventPaths...)
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): done", task.TaskID), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var final TaskState
|
||||
for _, item := range state.Tasks {
|
||||
if item.TaskID == task.TaskID {
|
||||
final = item
|
||||
break
|
||||
}
|
||||
}
|
||||
return a.printResult("task.done", map[string]any{"task": final, "events": newEvents, "commit": sha})
|
||||
}
|
||||
|
||||
func (a *App) runTaskList(root string, args []string) error {
|
||||
fs := newFlagSet("task list")
|
||||
status := fs.String("status", "", "filtrar por estado")
|
||||
mine := fs.Bool("mine", false, "sólo tareas asignadas o ejecutadas por el administrador actual")
|
||||
projectID := fs.String("project", "", "filtrar por proyecto")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return NewError(ExitUsage, "invalid_flags", err.Error(), nil)
|
||||
}
|
||||
state, err := a.BuildState(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var adminID string
|
||||
if *mine {
|
||||
identity, err := a.ResolveIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminID = identity.Admin.ID
|
||||
}
|
||||
items := []TaskState{}
|
||||
for _, item := range state.Tasks {
|
||||
if *status != "" && item.Status != strings.ToLower(*status) {
|
||||
continue
|
||||
}
|
||||
if *projectID != "" && item.ProjectID != normalizeID(*projectID) {
|
||||
continue
|
||||
}
|
||||
if *mine && !contains(item.Assignees, adminID) && !contains(item.RunningBy, adminID) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return a.printResult("task.list", map[string]any{"count": len(items), "items": items})
|
||||
}
|
||||
|
||||
func (a *App) persistTaskEvent(root string, identity Identity, event TaskEvent, action string) error {
|
||||
eventPaths, err := writeTaskEvents(root, []TaskEvent{event})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, changed, statePath, err := a.Sync(root, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := append([]string(nil), eventPaths...)
|
||||
if changed {
|
||||
paths = append(paths, statePath)
|
||||
}
|
||||
sha, err := a.commitPaths(root, identity, fmt.Sprintf("task(%s): %s", event.TaskID, action), paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var task TaskState
|
||||
for _, item := range state.Tasks {
|
||||
if item.TaskID == normalizeID(event.TaskID) {
|
||||
task = item
|
||||
break
|
||||
}
|
||||
}
|
||||
return a.printResult("task."+action, map[string]any{"event": event, "task": task, "commit": sha})
|
||||
}
|
||||
Reference in New Issue
Block a user