528 lines
16 KiB
Go
528 lines
16 KiB
Go
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})
|
|
}
|