From 6a9ca335eefed6712281eb536ddb4b17668379cd Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Sat, 21 Mar 2026 02:49:11 -0700 Subject: [PATCH 1/4] feat: add td history command for issue timeline view Adds a unified chronological timeline showing all events for an issue: logs, handoffs, comments, git snapshots, and status changes from the action_log. Supports --json and --limit flags. Nightshift-Task: historical-context Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/history.go | 116 ++++++++++++ internal/db/history.go | 368 ++++++++++++++++++++++++++++++++++++ internal/db/history_test.go | 301 +++++++++++++++++++++++++++++ internal/models/history.go | 24 +++ 4 files changed, 809 insertions(+) create mode 100644 cmd/history.go create mode 100644 internal/db/history.go create mode 100644 internal/db/history_test.go create mode 100644 internal/models/history.go diff --git a/cmd/history.go b/cmd/history.go new file mode 100644 index 00000000..f2bf3c61 --- /dev/null +++ b/cmd/history.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "fmt" + + "github.com/marcus/td/internal/db" + "github.com/marcus/td/internal/models" + "github.com/marcus/td/internal/output" + "github.com/spf13/cobra" +) + +var historyCmd = &cobra.Command{ + Use: "history ", + Aliases: []string{"timeline"}, + Short: "Show chronological timeline of an issue's lifecycle", + Long: `Display a unified chronological timeline of all events for an issue. + +Merges logs, handoffs, comments, git snapshots, and status changes +into a single narrative view. + +Examples: + td history td-abc1 # Full timeline + td history td-abc1 --limit 10 # Last 10 events + td history td-abc1 --json # Machine-readable output`, + GroupID: "query", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + issueID := args[0] + if err := ValidateIssueID(issueID, "history "); err != nil { + output.Error("%v", err) + return err + } + + baseDir := getBaseDir() + database, err := db.Open(baseDir) + if err != nil { + output.Error("%v", err) + return err + } + defer database.Close() + + // Verify issue exists + issue, err := database.GetIssue(issueID) + if err != nil { + output.Error("%v", err) + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + events, err := database.GetIssueTimeline(issueID, limit) + if err != nil { + output.Error("failed to build timeline: %v", err) + return err + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + if jsonOutput { + return output.JSON(map[string]any{ + "issue_id": issue.ID, + "title": issue.Title, + "status": issue.Status, + "events": events, + }) + } + + // Narrative output + fmt.Printf("Timeline: %s \"%s\" [%s]\n\n", issue.ID, issue.Title, issue.Status) + + if len(events) == 0 { + fmt.Println(" No events recorded.") + return nil + } + + for _, ev := range events { + icon := eventIcon(ev.EventType) + ts := output.FormatTimeAgo(ev.Timestamp) + sessionInfo := "" + if ev.SessionID != "" { + sessionInfo = fmt.Sprintf(" (%s)", ev.SessionID) + } + fmt.Printf(" %s %s %s%s\n", icon, ts, ev.Summary, sessionInfo) + if ev.Detail != "" { + // Indent detail lines + fmt.Printf(" %s\n", ev.Detail) + } + } + + return nil + }, +} + +func eventIcon(t models.EventType) string { + switch t { + case models.EventStatusChange: + return "~" + case models.EventLog: + return "#" + case models.EventHandoff: + return ">" + case models.EventGitSnapshot: + return "@" + case models.EventComment: + return "*" + case models.EventFileLink: + return "+" + default: + return "-" + } +} + +func init() { + rootCmd.AddCommand(historyCmd) + + historyCmd.Flags().Bool("json", false, "Machine-readable JSON output") + historyCmd.Flags().Int("limit", 0, "Limit to last N events") +} diff --git a/internal/db/history.go b/internal/db/history.go new file mode 100644 index 00000000..0ee75a5e --- /dev/null +++ b/internal/db/history.go @@ -0,0 +1,368 @@ +package db + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/marcus/td/internal/models" +) + +// GetIssueTimeline returns a chronologically sorted timeline of all events +// for a given issue: logs, handoffs, comments, git snapshots, and status +// changes from the action_log. +func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEvent, error) { + var events []models.TimelineEvent + + // 1. Logs + logs, err := db.GetLogs(issueID, 0) + if err != nil { + return nil, fmt.Errorf("query logs: %w", err) + } + for _, l := range logs { + events = append(events, models.TimelineEvent{ + Timestamp: l.Timestamp, + EventType: models.EventLog, + Summary: fmt.Sprintf("[%s] %s", l.Type, l.Message), + SessionID: l.SessionID, + }) + } + + // 2. Handoffs + handoffs, err := db.getHandoffsForIssue(issueID) + if err != nil { + return nil, fmt.Errorf("query handoffs: %w", err) + } + for _, h := range handoffs { + detail := formatHandoffDetail(h) + events = append(events, models.TimelineEvent{ + Timestamp: h.Timestamp, + EventType: models.EventHandoff, + Summary: "Handoff recorded", + Detail: detail, + SessionID: h.SessionID, + }) + } + + // 3. Comments + comments, err := db.GetComments(issueID) + if err != nil { + return nil, fmt.Errorf("query comments: %w", err) + } + for _, c := range comments { + summary := c.Text + if len(summary) > 80 { + summary = summary[:77] + "..." + } + events = append(events, models.TimelineEvent{ + Timestamp: c.CreatedAt, + EventType: models.EventComment, + Summary: summary, + Detail: c.Text, + SessionID: c.SessionID, + }) + } + + // 4. Git snapshots + snapshots, err := db.getGitSnapshots(issueID) + if err != nil { + return nil, fmt.Errorf("query git snapshots: %w", err) + } + for _, s := range snapshots { + summary := fmt.Sprintf("Git %s: %s on %s", s.Event, shortSHA(s.CommitSHA), s.Branch) + if s.DirtyFiles > 0 { + summary += fmt.Sprintf(" (%d dirty)", s.DirtyFiles) + } + events = append(events, models.TimelineEvent{ + Timestamp: s.Timestamp, + EventType: models.EventGitSnapshot, + Summary: summary, + }) + } + + // 5. Action log entries for this issue (status changes, file links, etc.) + actions, err := db.getActionLogForEntity(issueID) + if err != nil { + return nil, fmt.Errorf("query action log: %w", err) + } + for _, a := range actions { + ev := actionToTimelineEvent(a) + if ev != nil { + events = append(events, *ev) + } + } + + // Sort chronologically + sort.Slice(events, func(i, j int) bool { + return events[i].Timestamp.Before(events[j].Timestamp) + }) + + if limit > 0 && len(events) > limit { + events = events[len(events)-limit:] + } + + return events, nil +} + +// getHandoffsForIssue returns all handoffs for an issue, chronologically. +func (db *DB) getHandoffsForIssue(issueID string) ([]models.Handoff, error) { + rows, err := db.conn.Query(` + SELECT CAST(id AS TEXT), issue_id, session_id, done, remaining, decisions, uncertain, timestamp + FROM handoffs WHERE issue_id = ? ORDER BY timestamp + `, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + + var handoffs []models.Handoff + for rows.Next() { + var h models.Handoff + var doneJSON, remainingJSON, decisionsJSON, uncertainJSON string + if err := rows.Scan(&h.ID, &h.IssueID, &h.SessionID, + &doneJSON, &remainingJSON, &decisionsJSON, &uncertainJSON, &h.Timestamp); err != nil { + return nil, err + } + json.Unmarshal([]byte(doneJSON), &h.Done) + json.Unmarshal([]byte(remainingJSON), &h.Remaining) + json.Unmarshal([]byte(decisionsJSON), &h.Decisions) + json.Unmarshal([]byte(uncertainJSON), &h.Uncertain) + handoffs = append(handoffs, h) + } + return handoffs, nil +} + +// getGitSnapshots returns all git snapshots for an issue. +func (db *DB) getGitSnapshots(issueID string) ([]models.GitSnapshot, error) { + rows, err := db.conn.Query(` + SELECT CAST(id AS TEXT), issue_id, event, commit_sha, branch, dirty_files, timestamp + FROM git_snapshots WHERE issue_id = ? ORDER BY timestamp + `, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + + var snapshots []models.GitSnapshot + for rows.Next() { + var s models.GitSnapshot + if err := rows.Scan(&s.ID, &s.IssueID, &s.Event, &s.CommitSHA, &s.Branch, &s.DirtyFiles, &s.Timestamp); err != nil { + return nil, err + } + snapshots = append(snapshots, s) + } + return snapshots, nil +} + +// getActionLogForEntity returns action_log rows where entity_id matches the issue +// or entity_type is 'issue' with matching entity_id. +func (db *DB) getActionLogForEntity(issueID string) ([]models.ActionLog, error) { + rows, err := db.conn.Query(` + SELECT CAST(id AS TEXT), session_id, action_type, entity_type, entity_id, + previous_data, new_data, timestamp, undone + FROM action_log + WHERE entity_id = ? AND entity_type IN ('issue', 'issue_files') + ORDER BY timestamp + `, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []models.ActionLog + for rows.Next() { + var a models.ActionLog + var undone int + if err := rows.Scan(&a.ID, &a.SessionID, &a.ActionType, &a.EntityType, + &a.EntityID, &a.PreviousData, &a.NewData, &a.Timestamp, &undone); err != nil { + return nil, err + } + a.Undone = undone == 1 + actions = append(actions, a) + } + return actions, nil +} + +func actionToTimelineEvent(a models.ActionLog) *models.TimelineEvent { + if a.Undone { + return nil + } + + switch a.EntityType { + case "issue": + return issueActionToEvent(a) + case "issue_files": + return fileActionToEvent(a) + default: + return nil + } +} + +func issueActionToEvent(a models.ActionLog) *models.TimelineEvent { + switch models.ActionType(a.ActionType) { + case models.ActionCreate: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Issue created", + SessionID: a.SessionID, + } + case models.ActionStart: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Status changed to in_progress", + SessionID: a.SessionID, + } + case models.ActionReview: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Submitted for review", + SessionID: a.SessionID, + } + case models.ActionApprove: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Approved and closed", + SessionID: a.SessionID, + } + case models.ActionReject: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Rejected (needs rework)", + SessionID: a.SessionID, + } + case models.ActionBlock: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Blocked", + SessionID: a.SessionID, + } + case models.ActionUnblock: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Unblocked", + SessionID: a.SessionID, + } + case models.ActionClose: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Closed", + SessionID: a.SessionID, + } + case models.ActionReopen: + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Reopened", + SessionID: a.SessionID, + } + case models.ActionUpdate: + return issueUpdateToEvent(a) + default: + return nil + } +} + +func issueUpdateToEvent(a models.ActionLog) *models.TimelineEvent { + // Try to extract what changed from previous_data vs new_data + var prev, next map[string]interface{} + json.Unmarshal([]byte(a.PreviousData), &prev) + json.Unmarshal([]byte(a.NewData), &next) + + if prev == nil || next == nil { + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: "Issue updated", + SessionID: a.SessionID, + } + } + + // Check for status change specifically + oldStatus, _ := prev["status"].(string) + newStatus, _ := next["status"].(string) + if oldStatus != "" && newStatus != "" && oldStatus != newStatus { + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: fmt.Sprintf("Status: %s → %s", oldStatus, newStatus), + SessionID: a.SessionID, + } + } + + // Generic update - summarize changed fields + var changed []string + for key, newVal := range next { + if oldVal, ok := prev[key]; ok && fmt.Sprintf("%v", oldVal) != fmt.Sprintf("%v", newVal) { + changed = append(changed, key) + } + } + if len(changed) > 0 { + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventStatusChange, + Summary: fmt.Sprintf("Updated: %s", strings.Join(changed, ", ")), + SessionID: a.SessionID, + } + } + + return nil +} + +func fileActionToEvent(a models.ActionLog) *models.TimelineEvent { + var data map[string]interface{} + if a.NewData != "" { + json.Unmarshal([]byte(a.NewData), &data) + } else { + json.Unmarshal([]byte(a.PreviousData), &data) + } + + filePath, _ := data["file_path"].(string) + if filePath == "" { + filePath = "unknown" + } + + action := "Linked" + if models.ActionType(a.ActionType) == models.ActionUnlinkFile { + action = "Unlinked" + } + + return &models.TimelineEvent{ + Timestamp: a.Timestamp, + EventType: models.EventFileLink, + Summary: fmt.Sprintf("%s file: %s", action, filePath), + SessionID: a.SessionID, + } +} + +func formatHandoffDetail(h models.Handoff) string { + var parts []string + if len(h.Done) > 0 { + parts = append(parts, fmt.Sprintf("Done: %s", strings.Join(h.Done, "; "))) + } + if len(h.Remaining) > 0 { + parts = append(parts, fmt.Sprintf("Remaining: %s", strings.Join(h.Remaining, "; "))) + } + if len(h.Decisions) > 0 { + parts = append(parts, fmt.Sprintf("Decisions: %s", strings.Join(h.Decisions, "; "))) + } + if len(h.Uncertain) > 0 { + parts = append(parts, fmt.Sprintf("Uncertain: %s", strings.Join(h.Uncertain, "; "))) + } + return strings.Join(parts, "\n") +} + +func shortSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/internal/db/history_test.go b/internal/db/history_test.go new file mode 100644 index 00000000..3eae9a77 --- /dev/null +++ b/internal/db/history_test.go @@ -0,0 +1,301 @@ +package db + +import ( + "testing" + "time" + + "github.com/marcus/td/internal/models" +) + +func TestGetIssueTimeline_Empty(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Timeline test issue"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + events, err := db.GetIssueTimeline(issue.ID, 0) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + // CreateIssue (without Logged) doesn't write action_log, so 0 events is correct + if len(events) != 0 { + t.Errorf("expected 0 events for bare issue, got %d", len(events)) + } +} + +func TestGetIssueTimeline_ChronologicalOrder(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Chronological test"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add a log + log := &models.Log{ + IssueID: issue.ID, + SessionID: "ses_test", + Message: "First progress", + Type: models.LogTypeProgress, + } + if err := db.AddLog(log); err != nil { + t.Fatalf("AddLog failed: %v", err) + } + + // Add a comment + comment := &models.Comment{ + IssueID: issue.ID, + SessionID: "ses_test", + Text: "A review comment", + } + if err := db.AddComment(comment); err != nil { + t.Fatalf("AddComment failed: %v", err) + } + + // Add a handoff + handoff := &models.Handoff{ + IssueID: issue.ID, + SessionID: "ses_test", + Done: []string{"implemented feature"}, + Remaining: []string{"write tests"}, + } + if err := db.AddHandoff(handoff); err != nil { + t.Fatalf("AddHandoff failed: %v", err) + } + + events, err := db.GetIssueTimeline(issue.ID, 0) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + // Verify chronological order + for i := 1; i < len(events); i++ { + if events[i].Timestamp.Before(events[i-1].Timestamp) { + t.Errorf("event %d (%s at %v) is before event %d (%s at %v)", + i, events[i].EventType, events[i].Timestamp, + i-1, events[i-1].EventType, events[i-1].Timestamp) + } + } + + // Should have at least: log + comment + handoff (CreateIssue doesn't log to action_log) + if len(events) < 3 { + t.Errorf("expected at least 3 events, got %d", len(events)) + } +} + +func TestGetIssueTimeline_EventTypes(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Event types test"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add a status change via action_log + if err := db.LogAction(&models.ActionLog{ + SessionID: "ses_a", ActionType: models.ActionStart, + EntityType: "issue", EntityID: issue.ID, + NewData: `{"status":"in_progress"}`, + }); err != nil { + t.Fatalf("LogAction failed: %v", err) + } + + // Add log + if err := db.AddLog(&models.Log{ + IssueID: issue.ID, SessionID: "ses_a", + Message: "progress note", Type: models.LogTypeProgress, + }); err != nil { + t.Fatalf("AddLog failed: %v", err) + } + + // Add comment + if err := db.AddComment(&models.Comment{ + IssueID: issue.ID, SessionID: "ses_b", + Text: "looks good", + }); err != nil { + t.Fatalf("AddComment failed: %v", err) + } + + // Add git snapshot + if err := db.AddGitSnapshot(&models.GitSnapshot{ + IssueID: issue.ID, Event: "start", + CommitSHA: "abc1234567890", Branch: "main", DirtyFiles: 0, + }); err != nil { + t.Fatalf("AddGitSnapshot failed: %v", err) + } + + // Add handoff + if err := db.AddHandoff(&models.Handoff{ + IssueID: issue.ID, SessionID: "ses_a", + Done: []string{"done"}, Remaining: []string{"more"}, + }); err != nil { + t.Fatalf("AddHandoff failed: %v", err) + } + + events, err := db.GetIssueTimeline(issue.ID, 0) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + // Check that we have diverse event types + typeSet := make(map[models.EventType]bool) + for _, ev := range events { + typeSet[ev.EventType] = true + } + + expectedTypes := []models.EventType{ + models.EventStatusChange, + models.EventLog, + models.EventComment, + models.EventGitSnapshot, + models.EventHandoff, + } + for _, et := range expectedTypes { + if !typeSet[et] { + t.Errorf("expected event type %s not found in timeline", et) + } + } +} + +func TestGetIssueTimeline_Limit(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Limit test"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add several logs + for i := 0; i < 5; i++ { + if err := db.AddLog(&models.Log{ + IssueID: issue.ID, SessionID: "ses_test", + Message: "log entry", Type: models.LogTypeProgress, + }); err != nil { + t.Fatalf("AddLog failed: %v", err) + } + time.Sleep(time.Millisecond) // ensure distinct timestamps + } + + events, err := db.GetIssueTimeline(issue.ID, 3) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + if len(events) != 3 { + t.Errorf("expected 3 events with limit, got %d", len(events)) + } +} + +func TestGetIssueTimeline_SessionAttribution(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Session test"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + if err := db.AddLog(&models.Log{ + IssueID: issue.ID, SessionID: "ses_alpha", + Message: "from alpha", Type: models.LogTypeProgress, + }); err != nil { + t.Fatalf("AddLog failed: %v", err) + } + + if err := db.AddComment(&models.Comment{ + IssueID: issue.ID, SessionID: "ses_beta", + Text: "from beta", + }); err != nil { + t.Fatalf("AddComment failed: %v", err) + } + + events, err := db.GetIssueTimeline(issue.ID, 0) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + sessionSeen := make(map[string]bool) + for _, ev := range events { + if ev.SessionID != "" { + sessionSeen[ev.SessionID] = true + } + } + + if !sessionSeen["ses_alpha"] { + t.Error("expected ses_alpha in timeline events") + } + if !sessionSeen["ses_beta"] { + t.Error("expected ses_beta in timeline events") + } +} + +func TestGetIssueTimeline_StatusChangeFromActionLog(t *testing.T) { + dir := t.TempDir() + db, err := Initialize(dir) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + defer db.Close() + + issue := &models.Issue{Title: "Status change test"} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Simulate a start action in action_log + if err := db.LogAction(&models.ActionLog{ + SessionID: "ses_test", + ActionType: models.ActionStart, + EntityType: "issue", + EntityID: issue.ID, + NewData: `{"status":"in_progress"}`, + }); err != nil { + t.Fatalf("LogAction failed: %v", err) + } + + events, err := db.GetIssueTimeline(issue.ID, 0) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + + found := false + for _, ev := range events { + if ev.EventType == models.EventStatusChange && ev.Summary == "Status changed to in_progress" { + found = true + break + } + } + if !found { + t.Error("expected status_change event for start action") + for _, ev := range events { + t.Logf(" event: type=%s summary=%q", ev.EventType, ev.Summary) + } + } +} diff --git a/internal/models/history.go b/internal/models/history.go new file mode 100644 index 00000000..dff96bd8 --- /dev/null +++ b/internal/models/history.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// EventType categorizes timeline events +type EventType string + +const ( + EventStatusChange EventType = "status_change" + EventLog EventType = "log" + EventHandoff EventType = "handoff" + EventGitSnapshot EventType = "git_snapshot" + EventComment EventType = "comment" + EventFileLink EventType = "file_link" +) + +// TimelineEvent represents a single event in an issue's history +type TimelineEvent struct { + Timestamp time.Time `json:"timestamp"` + EventType EventType `json:"event_type"` + Summary string `json:"summary"` + Detail string `json:"detail,omitempty"` + SessionID string `json:"session_id,omitempty"` +} From 2e68351be47f87348043f71efe5ae652d4df5f0d Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Thu, 15 Oct 2026 16:07:37 -0700 Subject: [PATCH 2/4] fix(history): make td history accurate and readable Rework of the nightshift timeline before merge: - Fold the log a lifecycle command writes into its status-change event (same session, closest within -2s..+10s), so approvals show their reason once instead of as a duplicate line. Only transitions and reviews pair; a note logged right after td update stays a log. - Report all timestamps in UTC in JSON and local time in text. Action rows are UTC and logs carry a local offset, so dates were shown on the wrong day. - Show real field changes (priority: P2 -> P1), ignore updated_at-only updates, and sort changed fields deterministically. - Read linked files from issue_files; the action_log rows are keyed by link ID, so file events never matched before. - Cover review records, close-after-review, delete, and restore, and synthesize a created event when no create action exists. - Collapse identical consecutive events into one with a count. - Show short comments inline and long ones once, without cutting runes. - Reuse GetHandoffs and GetLinkedFiles, check rows.Err, fix errcheck. - Use absolute times with type labels, indent every detail line, and report errors through reportFailure so --json gets an error envelope. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 1 + cmd/history.go | 128 +++++--- cmd/history_test.go | 29 ++ internal/db/history.go | 504 +++++++++++++++++------------- internal/db/history_test.go | 433 ++++++++++++------------- internal/models/history.go | 9 +- website/docs/command-reference.md | 1 + 8 files changed, 605 insertions(+), 504 deletions(-) create mode 100644 cmd/history_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4760d92b..8cbccd8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to td are documented in this file. ## [Unreleased] +### CLI + +- **`td history ` (alias `td timeline`) shows an issue's whole life in order:** creation, status changes, field updates (`priority: P2 → P1`), review records, logs, handoffs, comments, git snapshots, and linked files. The log a lifecycle command writes, such as an approval's reason, appears under its status change instead of as a duplicate line. Identical consecutive events, such as a retry loop posting the same comment, collapse into one with a count. `--limit N` keeps the most recent N events; `--json` returns the events with UTC timestamps. + ## [v0.67.0] - 2026-10-15 ### Sync Server diff --git a/README.md b/README.md index 469f0036..853eca58 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Log a decision | `td log --decision "chose X because Y"` | | Log a blocker | `td log --blocker "stuck on X"` | | View issue details | `td show ` | +| View an issue's timeline | `td history ` | | Capture handoff state | `td handoff --done "..." --remaining "..."` | | Submit for review | `td review ` | | See reviewable issues | `td reviewable` | diff --git a/cmd/history.go b/cmd/history.go index f2bf3c61..165e6272 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/marcus/td/internal/db" "github.com/marcus/td/internal/models" @@ -15,8 +16,11 @@ var historyCmd = &cobra.Command{ Short: "Show chronological timeline of an issue's lifecycle", Long: `Display a unified chronological timeline of all events for an issue. -Merges logs, handoffs, comments, git snapshots, and status changes -into a single narrative view. +Merges status changes, field updates, reviews, logs, handoffs, comments, +git snapshots, and linked files into a single view. The log a lifecycle +command writes (e.g. an approval reason) is shown under its status change. + +Times are local in text output and UTC in --json output. Examples: td history td-abc1 # Full timeline @@ -25,36 +29,37 @@ Examples: GroupID: "query", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Args are validated by cobra; every later failure carries its own + // message, and for a --json caller usage text would corrupt the output. + cmd.SilenceUsage = true + isJSON := jsonMode(cmd) + issueID := args[0] if err := ValidateIssueID(issueID, "history "); err != nil { - output.Error("%v", err) - return err + return reportFailure(isJSON, output.ErrCodeInvalidInput, err) } - baseDir := getBaseDir() - database, err := db.Open(baseDir) + database, err := db.Open(getBaseDir()) if err != nil { - output.Error("%v", err) - return err + return reportFailure(isJSON, output.ErrCodeDatabaseError, err) } - defer database.Close() + defer func() { _ = database.Close() }() - // Verify issue exists issue, err := database.GetIssue(issueID) if err != nil { - output.Error("%v", err) - return err + return reportFailure(isJSON, output.ErrCodeNotFound, err) } limit, _ := cmd.Flags().GetInt("limit") - events, err := database.GetIssueTimeline(issueID, limit) + events, err := database.GetIssueTimeline(issue.ID, limit) if err != nil { - output.Error("failed to build timeline: %v", err) - return err + return reportFailure(isJSON, output.ErrCodeDatabaseError, fmt.Errorf("build timeline: %w", err)) + } + if events == nil { + events = []models.TimelineEvent{} } - jsonOutput, _ := cmd.Flags().GetBool("json") - if jsonOutput { + if isJSON { return output.JSON(map[string]any{ "issue_id": issue.ID, "title": issue.Title, @@ -63,49 +68,70 @@ Examples: }) } - // Narrative output - fmt.Printf("Timeline: %s \"%s\" [%s]\n\n", issue.ID, issue.Title, issue.Status) - + fmt.Printf("Timeline: %s %q [%s]\n\n", issue.ID, issue.Title, issue.Status) if len(events) == 0 { fmt.Println(" No events recorded.") return nil } - - for _, ev := range events { - icon := eventIcon(ev.EventType) - ts := output.FormatTimeAgo(ev.Timestamp) - sessionInfo := "" - if ev.SessionID != "" { - sessionInfo = fmt.Sprintf(" (%s)", ev.SessionID) - } - fmt.Printf(" %s %s %s%s\n", icon, ts, ev.Summary, sessionInfo) - if ev.Detail != "" { - // Indent detail lines - fmt.Printf(" %s\n", ev.Detail) - } - } - + printTimeline(events) return nil }, } -func eventIcon(t models.EventType) string { - switch t { - case models.EventStatusChange: - return "~" - case models.EventLog: - return "#" - case models.EventHandoff: - return ">" - case models.EventGitSnapshot: - return "@" - case models.EventComment: - return "*" - case models.EventFileLink: - return "+" - default: - return "-" +// historyIndent aligns continuation lines under an event's summary: +// two spaces, a 16-char time, two spaces, a padded label, a space. +var historyIndent = strings.Repeat(" ", 2+16+2+historyLabelWidth+1) + +const historyLabelWidth = 7 + +var historyLabels = map[models.EventType]string{ + models.EventCreated: "created", + models.EventStatusChange: "status", + models.EventUpdate: "update", + models.EventReview: "review", + models.EventLog: "log", + models.EventHandoff: "handoff", + models.EventGitSnapshot: "git", + models.EventComment: "comment", + models.EventFileLink: "file", +} + +func printTimeline(events []models.TimelineEvent) { + lastSession := "" + for _, ev := range events { + label := historyLabels[ev.EventType] + if label == "" { + label = string(ev.EventType) + } + summary := indentContinuation(ev.Summary) + if ev.Count > 1 { + summary += fmt.Sprintf(" (×%d)", ev.Count) + } + // Name the session only when it changes, so a run of work by one + // session reads as a block. + if ev.SessionID != "" && ev.SessionID != lastSession { + summary += fmt.Sprintf(" (%s)", ev.SessionID) + lastSession = ev.SessionID + } + fmt.Printf(" %s %-*s %s\n", ev.Timestamp.Local().Format("2006-01-02 15:04"), historyLabelWidth, label, summary) + if ev.Detail != "" { + fmt.Printf("%s%s\n", historyIndent, indentContinuation(ev.Detail)) + } + } +} + +// indentContinuation indents every line after the first, leaving blank +// lines empty. +func indentContinuation(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) != "" { + lines[i] = historyIndent + lines[i] + } else { + lines[i] = "" + } } + return strings.Join(lines, "\n") } func init() { diff --git a/cmd/history_test.go b/cmd/history_test.go new file mode 100644 index 00000000..f1080e24 --- /dev/null +++ b/cmd/history_test.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/marcus/td/internal/models" +) + +func TestPrintTimeline(t *testing.T) { + at := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + events := []models.TimelineEvent{ + {Timestamp: at, EventType: models.EventStatusChange, Summary: "Started work", SessionID: "ses_a"}, + {Timestamp: at, EventType: models.EventHandoff, Summary: "Handoff", Detail: "Done: x\n\nRemaining: y", SessionID: "ses_a"}, + {Timestamp: at, EventType: models.EventComment, Summary: "Comment: retry", SessionID: "ses_b", Count: 3}, + } + stamp := at.Local().Format("2006-01-02 15:04") + + got := captureStdout(t, func() { printTimeline(events) }) + want := " " + stamp + " status Started work (ses_a)\n" + + " " + stamp + " handoff Handoff\n" + + historyIndent + "Done: x\n" + + "\n" + + historyIndent + "Remaining: y\n" + + " " + stamp + " comment Comment: retry (×3) (ses_b)\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} diff --git a/internal/db/history.go b/internal/db/history.go index 0ee75a5e..6e66507a 100644 --- a/internal/db/history.go +++ b/internal/db/history.go @@ -3,68 +3,107 @@ package db import ( "encoding/json" "fmt" + "reflect" "sort" "strings" + "time" + "unicode/utf8" "github.com/marcus/td/internal/models" ) -// GetIssueTimeline returns a chronologically sorted timeline of all events -// for a given issue: logs, handoffs, comments, git snapshots, and status -// changes from the action_log. +// Lifecycle commands write an action_log row and a log entry for the same +// transition, a few seconds apart (start takes a git snapshot in between). +// A log from the same session inside this window is folded into the +// transition's event as its detail instead of appearing as a separate event. +const ( + timelinePairBefore = 2 * time.Second + timelinePairAfter = 10 * time.Second +) + +// commentSummaryLen is the longest comment, in runes, shown inline in the +// summary rather than as detail. +const commentSummaryLen = 80 + +// GetIssueTimeline returns a chronologically sorted timeline of an issue: +// lifecycle actions, field updates, logs, handoffs, comments, git snapshots, +// and linked files. If limit > 0, only the most recent limit events are +// returned. Event timestamps are UTC. func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEvent, error) { - var events []models.TimelineEvent + issue, err := db.GetIssue(issueID) + if err != nil { + return nil, err + } + issueID = issue.ID + + actions, err := db.getIssueActions(issueID) + if err != nil { + return nil, fmt.Errorf("query action log: %w", err) + } + var actionEvents []models.TimelineEvent + hasCreate := false + for _, a := range actions { + ev, ok := issueActionToEvent(a) + if !ok { + continue + } + if ev.EventType == models.EventCreated { + hasCreate = true + } + actionEvents = append(actionEvents, ev) + } + // Issues created without an action_log row (imports, older data) still + // get a starting point. + if !hasCreate && !issue.CreatedAt.IsZero() { + actionEvents = append([]models.TimelineEvent{{ + Timestamp: issue.CreatedAt, + EventType: models.EventCreated, + Summary: createdSummary(string(issue.Type), string(issue.Priority)), + }}, actionEvents...) + } - // 1. Logs logs, err := db.GetLogs(issueID, 0) if err != nil { return nil, fmt.Errorf("query logs: %w", err) } - for _, l := range logs { - events = append(events, models.TimelineEvent{ - Timestamp: l.Timestamp, - EventType: models.EventLog, - Summary: fmt.Sprintf("[%s] %s", l.Type, l.Message), - SessionID: l.SessionID, - }) - } + events := append(actionEvents, foldLogsIntoActions(actionEvents, logs)...) - // 2. Handoffs - handoffs, err := db.getHandoffsForIssue(issueID) + handoffs, err := db.GetHandoffs(issueID) if err != nil { return nil, fmt.Errorf("query handoffs: %w", err) } for _, h := range handoffs { - detail := formatHandoffDetail(h) events = append(events, models.TimelineEvent{ Timestamp: h.Timestamp, EventType: models.EventHandoff, - Summary: "Handoff recorded", - Detail: detail, + Summary: "Handoff", + Detail: formatHandoffDetail(h), SessionID: h.SessionID, }) } - // 3. Comments comments, err := db.GetComments(issueID) if err != nil { return nil, fmt.Errorf("query comments: %w", err) } for _, c := range comments { - summary := c.Text - if len(summary) > 80 { - summary = summary[:77] + "..." - } - events = append(events, models.TimelineEvent{ + // A short one-line comment fits in the summary; anything longer is + // shown once, in full, as the detail. + ev := models.TimelineEvent{ Timestamp: c.CreatedAt, EventType: models.EventComment, - Summary: summary, - Detail: c.Text, + Summary: "Comment", SessionID: c.SessionID, - }) + } + text := strings.TrimSpace(c.Text) + if utf8.RuneCountInString(text) <= commentSummaryLen && !strings.Contains(text, "\n") { + ev.Summary += ": " + text + } else { + ev.Detail = text + } + events = append(events, ev) } - // 4. Git snapshots snapshots, err := db.getGitSnapshots(issueID) if err != nil { return nil, fmt.Errorf("query git snapshots: %w", err) @@ -81,56 +120,115 @@ func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEven }) } - // 5. Action log entries for this issue (status changes, file links, etc.) - actions, err := db.getActionLogForEntity(issueID) + files, err := db.GetLinkedFiles(issueID) if err != nil { - return nil, fmt.Errorf("query action log: %w", err) + return nil, fmt.Errorf("query linked files: %w", err) } - for _, a := range actions { - ev := actionToTimelineEvent(a) - if ev != nil { - events = append(events, *ev) - } + for _, f := range files { + events = append(events, models.TimelineEvent{ + Timestamp: f.LinkedAt, + EventType: models.EventFileLink, + Summary: fmt.Sprintf("Linked file: %s (%s)", f.FilePath, f.Role), + }) } - // Sort chronologically - sort.Slice(events, func(i, j int) bool { + for i := range events { + events[i].Timestamp = events[i].Timestamp.UTC() + } + sort.SliceStable(events, func(i, j int) bool { return events[i].Timestamp.Before(events[j].Timestamp) }) + events = collapseRepeats(events) + if limit > 0 && len(events) > limit { events = events[len(events)-limit:] } - return events, nil } -// getHandoffsForIssue returns all handoffs for an issue, chronologically. -func (db *DB) getHandoffsForIssue(issueID string) ([]models.Handoff, error) { - rows, err := db.conn.Query(` - SELECT CAST(id AS TEXT), issue_id, session_id, done, remaining, decisions, uncertain, timestamp - FROM handoffs WHERE issue_id = ? ORDER BY timestamp - `, issueID) - if err != nil { - return nil, err +// collapseRepeats merges runs of identical consecutive events into one +// event carrying a Count. +func collapseRepeats(events []models.TimelineEvent) []models.TimelineEvent { + var out []models.TimelineEvent + for _, ev := range events { + if n := len(out); n > 0 { + last := &out[n-1] + if last.EventType == ev.EventType && last.Summary == ev.Summary && + last.Detail == ev.Detail && last.SessionID == ev.SessionID { + if last.Count == 0 { + last.Count = 1 + } + last.Count++ + continue + } + } + out = append(out, ev) } - defer rows.Close() + return out +} - var handoffs []models.Handoff - for rows.Next() { - var h models.Handoff - var doneJSON, remainingJSON, decisionsJSON, uncertainJSON string - if err := rows.Scan(&h.ID, &h.IssueID, &h.SessionID, - &doneJSON, &remainingJSON, &decisionsJSON, &uncertainJSON, &h.Timestamp); err != nil { - return nil, err +// foldLogsIntoActions attaches each lifecycle log to the action event it +// narrates and returns the remaining logs as events of their own. A log +// pairs with an action from the same session within the pairing window; +// closest pairs are matched first, and each side pairs at most once. A log +// that only repeats (a prefix of) the action's summary, such as "Approved" +// for "Approved and closed", is dropped rather than shown twice. +func foldLogsIntoActions(actions []models.TimelineEvent, logs []models.Log) []models.TimelineEvent { + type candidate struct { + action, log int + gap time.Duration + } + var candidates []candidate + for ai, a := range actions { + // Only transitions and reviews narrate themselves with a log; an + // update would otherwise swallow a note logged right after it. + if (a.EventType != models.EventStatusChange && a.EventType != models.EventReview) || a.SessionID == "" { + continue + } + for li, l := range logs { + if l.SessionID != a.SessionID { + continue + } + gap := l.Timestamp.Sub(a.Timestamp) + if gap < -timelinePairBefore || gap > timelinePairAfter { + continue + } + candidates = append(candidates, candidate{ai, li, gap.Abs()}) + } + } + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].gap < candidates[j].gap }) + + actionPaired := make([]bool, len(actions)) + logPaired := make([]bool, len(logs)) + for _, c := range candidates { + if actionPaired[c.action] || logPaired[c.log] { + continue + } + actionPaired[c.action], logPaired[c.log] = true, true + msg := strings.TrimSpace(logs[c.log].Message) + if !strings.HasPrefix(strings.ToLower(actions[c.action].Summary), strings.ToLower(msg)) { + actions[c.action].Detail = msg + } + } + + var rest []models.TimelineEvent + for li, l := range logs { + if logPaired[li] { + continue + } + summary := l.Message + if l.Type != "" && l.Type != models.LogTypeProgress { + summary = fmt.Sprintf("[%s] %s", l.Type, l.Message) } - json.Unmarshal([]byte(doneJSON), &h.Done) - json.Unmarshal([]byte(remainingJSON), &h.Remaining) - json.Unmarshal([]byte(decisionsJSON), &h.Decisions) - json.Unmarshal([]byte(uncertainJSON), &h.Uncertain) - handoffs = append(handoffs, h) + rest = append(rest, models.TimelineEvent{ + Timestamp: l.Timestamp, + EventType: models.EventLog, + Summary: summary, + SessionID: l.SessionID, + }) } - return handoffs, nil + return rest } // getGitSnapshots returns all git snapshots for an issue. @@ -142,7 +240,7 @@ func (db *DB) getGitSnapshots(issueID string) ([]models.GitSnapshot, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var snapshots []models.GitSnapshot for rows.Next() { @@ -152,210 +250,174 @@ func (db *DB) getGitSnapshots(issueID string) ([]models.GitSnapshot, error) { } snapshots = append(snapshots, s) } - return snapshots, nil + return snapshots, rows.Err() } -// getActionLogForEntity returns action_log rows where entity_id matches the issue -// or entity_type is 'issue' with matching entity_id. -func (db *DB) getActionLogForEntity(issueID string) ([]models.ActionLog, error) { +// getIssueActions returns the issue's own action_log rows, oldest first, +// excluding undone actions. +func (db *DB) getIssueActions(issueID string) ([]models.ActionLog, error) { rows, err := db.conn.Query(` SELECT CAST(id AS TEXT), session_id, action_type, entity_type, entity_id, - previous_data, new_data, timestamp, undone + previous_data, new_data, timestamp FROM action_log - WHERE entity_id = ? AND entity_type IN ('issue', 'issue_files') + WHERE entity_type = 'issue' AND entity_id = ? AND undone = 0 ORDER BY timestamp `, issueID) if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var actions []models.ActionLog for rows.Next() { var a models.ActionLog - var undone int if err := rows.Scan(&a.ID, &a.SessionID, &a.ActionType, &a.EntityType, - &a.EntityID, &a.PreviousData, &a.NewData, &a.Timestamp, &undone); err != nil { + &a.EntityID, &a.PreviousData, &a.NewData, &a.Timestamp); err != nil { return nil, err } - a.Undone = undone == 1 actions = append(actions, a) } - return actions, nil + return actions, rows.Err() } -func actionToTimelineEvent(a models.ActionLog) *models.TimelineEvent { - if a.Undone { - return nil - } +// actionSummaries maps lifecycle actions to their timeline event. +var actionSummaries = map[models.ActionType]struct { + eventType models.EventType + summary string +}{ + models.ActionStart: {models.EventStatusChange, "Started work"}, + models.ActionReview: {models.EventStatusChange, "Submitted for review"}, + models.ActionApprove: {models.EventStatusChange, "Approved and closed"}, + models.ActionCloseAfterReview: {models.EventStatusChange, "Closed after review"}, + models.ActionReject: {models.EventStatusChange, "Rejected"}, + models.ActionBlock: {models.EventStatusChange, "Blocked"}, + models.ActionUnblock: {models.EventStatusChange, "Unblocked"}, + models.ActionClose: {models.EventStatusChange, "Closed"}, + models.ActionReopen: {models.EventStatusChange, "Reopened"}, + models.ActionDelete: {models.EventStatusChange, "Deleted"}, + models.ActionRestore: {models.EventStatusChange, "Restored"}, + models.ActionReviewApprove: {models.EventReview, "Review recorded: approved"}, + models.ActionReviewChangesRequested: {models.EventReview, "Review recorded: changes requested"}, + models.ActionReviewDelete: {models.EventReview, "Review record removed"}, +} - switch a.EntityType { - case "issue": - return issueActionToEvent(a) - case "issue_files": - return fileActionToEvent(a) - default: - return nil +func issueActionToEvent(a models.ActionLog) (models.TimelineEvent, bool) { + ev := models.TimelineEvent{Timestamp: a.Timestamp, SessionID: a.SessionID} + switch a.ActionType { + case models.ActionUpdate: + return issueUpdateToEvent(a, ev) + case models.ActionCreate: + var created struct{ Type, Priority string } + _ = json.Unmarshal([]byte(a.NewData), &created) // best effort: the summary just omits what it can't read + ev.EventType, ev.Summary = models.EventCreated, createdSummary(created.Type, created.Priority) + return ev, true + } + s, ok := actionSummaries[a.ActionType] + if !ok { + return ev, false } + ev.EventType, ev.Summary = s.eventType, s.summary + return ev, true } -func issueActionToEvent(a models.ActionLog) *models.TimelineEvent { - switch models.ActionType(a.ActionType) { - case models.ActionCreate: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Issue created", - SessionID: a.SessionID, - } - case models.ActionStart: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Status changed to in_progress", - SessionID: a.SessionID, - } - case models.ActionReview: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Submitted for review", - SessionID: a.SessionID, - } - case models.ActionApprove: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Approved and closed", - SessionID: a.SessionID, - } - case models.ActionReject: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Rejected (needs rework)", - SessionID: a.SessionID, - } - case models.ActionBlock: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Blocked", - SessionID: a.SessionID, - } - case models.ActionUnblock: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Unblocked", - SessionID: a.SessionID, - } - case models.ActionClose: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Closed", - SessionID: a.SessionID, - } - case models.ActionReopen: - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Reopened", - SessionID: a.SessionID, - } - case models.ActionUpdate: - return issueUpdateToEvent(a) - default: - return nil +// createdSummary reads "Created task (P1)", dropping whatever is unknown. +func createdSummary(issueType, priority string) string { + s := "Created" + if issueType != "" { + s += " " + issueType } + if priority != "" { + s += " (" + priority + ")" + } + return s } -func issueUpdateToEvent(a models.ActionLog) *models.TimelineEvent { - // Try to extract what changed from previous_data vs new_data - var prev, next map[string]interface{} - json.Unmarshal([]byte(a.PreviousData), &prev) - json.Unmarshal([]byte(a.NewData), &next) - - if prev == nil || next == nil { - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: "Issue updated", - SessionID: a.SessionID, - } +// timelineIgnoredFields change on every write and say nothing about the issue. +var timelineIgnoredFields = map[string]bool{"updated_at": true} + +// issueUpdateToEvent describes an update by the fields it changed. An update +// that changed only bookkeeping fields produces no event. +func issueUpdateToEvent(a models.ActionLog, ev models.TimelineEvent) (models.TimelineEvent, bool) { + var prev, next map[string]any + if json.Unmarshal([]byte(a.PreviousData), &prev) != nil || json.Unmarshal([]byte(a.NewData), &next) != nil || + prev == nil || next == nil { + ev.EventType, ev.Summary = models.EventUpdate, "Updated" + return ev, true } - // Check for status change specifically - oldStatus, _ := prev["status"].(string) - newStatus, _ := next["status"].(string) - if oldStatus != "" && newStatus != "" && oldStatus != newStatus { - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: fmt.Sprintf("Status: %s → %s", oldStatus, newStatus), - SessionID: a.SessionID, + var keys []string + for k := range next { + if !timelineIgnoredFields[k] && !reflect.DeepEqual(prev[k], next[k]) { + keys = append(keys, k) } } + if len(keys) == 0 { + return ev, false + } + sort.Strings(keys) - // Generic update - summarize changed fields - var changed []string - for key, newVal := range next { - if oldVal, ok := prev[key]; ok && fmt.Sprintf("%v", oldVal) != fmt.Sprintf("%v", newVal) { - changed = append(changed, key) + var changes []string + statusChanged := false + for _, k := range keys { + if k == "status" { + statusChanged = true } + changes = append(changes, describeFieldChange(k, prev[k], next[k])) } - if len(changed) > 0 { - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventStatusChange, - Summary: fmt.Sprintf("Updated: %s", strings.Join(changed, ", ")), - SessionID: a.SessionID, - } + ev.EventType = models.EventUpdate + if statusChanged { + ev.EventType = models.EventStatusChange } - - return nil + ev.Summary = "Updated " + strings.Join(changes, ", ") + return ev, true } -func fileActionToEvent(a models.ActionLog) *models.TimelineEvent { - var data map[string]interface{} - if a.NewData != "" { - json.Unmarshal([]byte(a.NewData), &data) - } else { - json.Unmarshal([]byte(a.PreviousData), &data) - } +// describeFieldChange shows short scalar changes as "field: old → new" and +// anything longer by name only. +func describeFieldChange(field string, oldVal, newVal any) string { + oldS, okOld := shortScalar(oldVal) + newS, okNew := shortScalar(newVal) + if !okOld || !okNew { + return field + } + return fmt.Sprintf("%s: %s → %s", field, oldS, newS) +} - filePath, _ := data["file_path"].(string) - if filePath == "" { - filePath = "unknown" +func shortScalar(v any) (string, bool) { + var s string + switch x := v.(type) { + case nil: + s = "(none)" + case string: + s = x + if s == "" { + s = "(none)" + } + case bool, float64: + s = fmt.Sprint(x) + default: + return "", false } - - action := "Linked" - if models.ActionType(a.ActionType) == models.ActionUnlinkFile { - action = "Unlinked" - } - - return &models.TimelineEvent{ - Timestamp: a.Timestamp, - EventType: models.EventFileLink, - Summary: fmt.Sprintf("%s file: %s", action, filePath), - SessionID: a.SessionID, + if utf8.RuneCountInString(s) > 40 || strings.Contains(s, "\n") { + return "", false } + return s, true } func formatHandoffDetail(h models.Handoff) string { var parts []string - if len(h.Done) > 0 { - parts = append(parts, fmt.Sprintf("Done: %s", strings.Join(h.Done, "; "))) - } - if len(h.Remaining) > 0 { - parts = append(parts, fmt.Sprintf("Remaining: %s", strings.Join(h.Remaining, "; "))) - } - if len(h.Decisions) > 0 { - parts = append(parts, fmt.Sprintf("Decisions: %s", strings.Join(h.Decisions, "; "))) - } - if len(h.Uncertain) > 0 { - parts = append(parts, fmt.Sprintf("Uncertain: %s", strings.Join(h.Uncertain, "; "))) + for _, section := range []struct { + label string + items []string + }{ + {"Done", h.Done}, + {"Remaining", h.Remaining}, + {"Decisions", h.Decisions}, + {"Uncertain", h.Uncertain}, + } { + if len(section.items) > 0 { + parts = append(parts, section.label+": "+strings.Join(section.items, "; ")) + } } return strings.Join(parts, "\n") } diff --git a/internal/db/history_test.go b/internal/db/history_test.go index 3eae9a77..b338b35f 100644 --- a/internal/db/history_test.go +++ b/internal/db/history_test.go @@ -1,301 +1,272 @@ package db import ( + "fmt" + "strings" "testing" "time" "github.com/marcus/td/internal/models" ) -func TestGetIssueTimeline_Empty(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) +func newTimelineDB(t *testing.T) (*DB, *models.Issue) { + t.Helper() + db, err := Initialize(t.TempDir()) if err != nil { t.Fatalf("Initialize failed: %v", err) } - defer db.Close() + t.Cleanup(func() { _ = db.Close() }) - issue := &models.Issue{Title: "Timeline test issue"} + issue := &models.Issue{Title: "Timeline test", Type: models.TypeTask, Priority: models.PriorityP2} if err := db.CreateIssue(issue); err != nil { t.Fatalf("CreateIssue failed: %v", err) } - - events, err := db.GetIssueTimeline(issue.ID, 0) - if err != nil { - t.Fatalf("GetIssueTimeline failed: %v", err) - } - - // CreateIssue (without Logged) doesn't write action_log, so 0 events is correct - if len(events) != 0 { - t.Errorf("expected 0 events for bare issue, got %d", len(events)) - } + return db, issue } -func TestGetIssueTimeline_ChronologicalOrder(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) - if err != nil { - t.Fatalf("Initialize failed: %v", err) - } - defer db.Close() - - issue := &models.Issue{Title: "Chronological test"} - if err := db.CreateIssue(issue); err != nil { - t.Fatalf("CreateIssue failed: %v", err) - } - - // Add a log - log := &models.Log{ - IssueID: issue.ID, - SessionID: "ses_test", - Message: "First progress", - Type: models.LogTypeProgress, - } - if err := db.AddLog(log); err != nil { - t.Fatalf("AddLog failed: %v", err) - } +var timelineSeq int - // Add a comment - comment := &models.Comment{ - IssueID: issue.ID, - SessionID: "ses_test", - Text: "A review comment", - } - if err := db.AddComment(comment); err != nil { - t.Fatalf("AddComment failed: %v", err) +// addActionAt writes an issue action_log row at an exact time. +func addActionAt(t *testing.T, db *DB, issueID, session string, action models.ActionType, prev, next string, at time.Time) { + t.Helper() + timelineSeq++ + if _, err := db.conn.Exec(` + INSERT INTO action_log (id, session_id, action_type, entity_type, entity_id, previous_data, new_data, timestamp, undone) + VALUES (?, ?, ?, 'issue', ?, ?, ?, ?, 0)`, + fmt.Sprintf("al-test%04d", timelineSeq), session, action, issueID, prev, next, formatActionLogTimestamp(at.UTC()), + ); err != nil { + t.Fatalf("insert action: %v", err) } +} - // Add a handoff - handoff := &models.Handoff{ - IssueID: issue.ID, - SessionID: "ses_test", - Done: []string{"implemented feature"}, - Remaining: []string{"write tests"}, - } - if err := db.AddHandoff(handoff); err != nil { - t.Fatalf("AddHandoff failed: %v", err) +// addLogAt writes a log at an exact time, in local time as AddLog does. +func addLogAt(t *testing.T, db *DB, issueID, session, msg string, typ models.LogType, at time.Time) { + t.Helper() + timelineSeq++ + if _, err := db.conn.Exec(` + INSERT INTO logs (id, issue_id, session_id, work_session_id, message, type, timestamp) + VALUES (?, ?, ?, '', ?, ?, ?)`, + fmt.Sprintf("lg-test%04d", timelineSeq), issueID, session, msg, typ, at.Local(), + ); err != nil { + t.Fatalf("insert log: %v", err) } +} - events, err := db.GetIssueTimeline(issue.ID, 0) +func timeline(t *testing.T, db *DB, issueID string, limit int) []models.TimelineEvent { + t.Helper() + events, err := db.GetIssueTimeline(issueID, limit) if err != nil { t.Fatalf("GetIssueTimeline failed: %v", err) } + return events +} - // Verify chronological order - for i := 1; i < len(events); i++ { - if events[i].Timestamp.Before(events[i-1].Timestamp) { - t.Errorf("event %d (%s at %v) is before event %d (%s at %v)", - i, events[i].EventType, events[i].Timestamp, - i-1, events[i-1].EventType, events[i-1].Timestamp) +func summaries(events []models.TimelineEvent) string { + var b strings.Builder + for _, ev := range events { + fmt.Fprintf(&b, "%s: %s", ev.EventType, ev.Summary) + if ev.Detail != "" { + fmt.Fprintf(&b, " / %s", ev.Detail) } + if ev.Count > 0 { + fmt.Fprintf(&b, " x%d", ev.Count) + } + b.WriteString("\n") } - - // Should have at least: log + comment + handoff (CreateIssue doesn't log to action_log) - if len(events) < 3 { - t.Errorf("expected at least 3 events, got %d", len(events)) - } + return b.String() } -func TestGetIssueTimeline_EventTypes(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) - if err != nil { - t.Fatalf("Initialize failed: %v", err) - } - defer db.Close() - - issue := &models.Issue{Title: "Event types test"} - if err := db.CreateIssue(issue); err != nil { - t.Fatalf("CreateIssue failed: %v", err) - } - - // Add a status change via action_log - if err := db.LogAction(&models.ActionLog{ - SessionID: "ses_a", ActionType: models.ActionStart, - EntityType: "issue", EntityID: issue.ID, - NewData: `{"status":"in_progress"}`, - }); err != nil { - t.Fatalf("LogAction failed: %v", err) - } - - // Add log - if err := db.AddLog(&models.Log{ - IssueID: issue.ID, SessionID: "ses_a", - Message: "progress note", Type: models.LogTypeProgress, - }); err != nil { - t.Fatalf("AddLog failed: %v", err) +func TestGetIssueTimeline_UnknownIssue(t *testing.T) { + db, _ := newTimelineDB(t) + if _, err := db.GetIssueTimeline("td-nope00", 0); err == nil { + t.Fatal("expected an error for an unknown issue") } +} - // Add comment - if err := db.AddComment(&models.Comment{ - IssueID: issue.ID, SessionID: "ses_b", - Text: "looks good", - }); err != nil { - t.Fatalf("AddComment failed: %v", err) +func TestGetIssueTimeline_SynthesizesCreated(t *testing.T) { + // CreateIssue writes no action_log row; the timeline still starts with + // the issue's creation. + db, issue := newTimelineDB(t) + events := timeline(t, db, issue.ID, 0) + if len(events) != 1 || events[0].EventType != models.EventCreated || events[0].Summary != "Created task (P2)" { + t.Fatalf("got:\n%s", summaries(events)) } +} - // Add git snapshot - if err := db.AddGitSnapshot(&models.GitSnapshot{ - IssueID: issue.ID, Event: "start", - CommitSHA: "abc1234567890", Branch: "main", DirtyFiles: 0, - }); err != nil { - t.Fatalf("AddGitSnapshot failed: %v", err) - } +func TestGetIssueTimeline_ChronologicalAndUTC(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) - // Add handoff - if err := db.AddHandoff(&models.Handoff{ - IssueID: issue.ID, SessionID: "ses_a", - Done: []string{"done"}, Remaining: []string{"more"}, - }); err != nil { - t.Fatalf("AddHandoff failed: %v", err) - } + // Logs are stored in local time and actions in UTC; the timeline must + // order them by instant and report all of them in UTC. + addLogAt(t, db, issue.ID, "ses_a", "second", models.LogTypeProgress, base.Add(2*time.Minute)) + addActionAt(t, db, issue.ID, "ses_b", models.ActionBlock, "", "", base.Add(time.Minute)) + addLogAt(t, db, issue.ID, "ses_a", "fourth", models.LogTypeProgress, base.Add(4*time.Minute)) + addActionAt(t, db, issue.ID, "ses_b", models.ActionUnblock, "", "", base.Add(3*time.Minute)) - events, err := db.GetIssueTimeline(issue.ID, 0) - if err != nil { - t.Fatalf("GetIssueTimeline failed: %v", err) + events := timeline(t, db, issue.ID, 0) + want := "created: Created task (P2)\nstatus_change: Blocked\nlog: second\nstatus_change: Unblocked\nlog: fourth\n" + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } - - // Check that we have diverse event types - typeSet := make(map[models.EventType]bool) for _, ev := range events { - typeSet[ev.EventType] = true - } - - expectedTypes := []models.EventType{ - models.EventStatusChange, - models.EventLog, - models.EventComment, - models.EventGitSnapshot, - models.EventHandoff, - } - for _, et := range expectedTypes { - if !typeSet[et] { - t.Errorf("expected event type %s not found in timeline", et) + if ev.Timestamp.Location() != time.UTC { + t.Errorf("%s timestamp %v is not UTC", ev.Summary, ev.Timestamp) } } } -func TestGetIssueTimeline_Limit(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) - if err != nil { - t.Fatalf("Initialize failed: %v", err) +func TestGetIssueTimeline_FoldsLifecycleLogs(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + // start writes "Started work" a few seconds after its action: it only + // repeats the summary, so it disappears. + addActionAt(t, db, issue.ID, "ses_a", models.ActionStart, "", "", base) + addLogAt(t, db, issue.ID, "ses_a", "Started work", models.LogTypeProgress, base.Add(4*time.Second)) + // Another session's log at the same moment is not folded. + addLogAt(t, db, issue.ID, "ses_other", "unrelated note", models.LogTypeProgress, base.Add(5*time.Second)) + // The same session's log well outside the window is not folded either. + addLogAt(t, db, issue.ID, "ses_a", "later note", models.LogTypeProgress, base.Add(time.Minute)) + + // review then approve one second apart: each log goes to its own action. + r := base.Add(10 * time.Minute) + addActionAt(t, db, issue.ID, "ses_r", models.ActionReview, "", "", r) + addActionAt(t, db, issue.ID, "ses_r", models.ActionApprove, "", "", r.Add(time.Second)) + addLogAt(t, db, issue.ID, "ses_r", "Submitted for review", models.LogTypeProgress, r.Add(100*time.Millisecond)) + addLogAt(t, db, issue.ID, "ses_r", "Approved (self-review): tests pass", models.LogTypeSecurity, r.Add(1100*time.Millisecond)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "status_change: Started work", + "log: unrelated note", + "log: later note", + "status_change: Submitted for review", + "status_change: Approved and closed / Approved (self-review): tests pass", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } - defer db.Close() +} - issue := &models.Issue{Title: "Limit test"} - if err := db.CreateIssue(issue); err != nil { - t.Fatalf("CreateIssue failed: %v", err) - } +func TestGetIssueTimeline_UpdateDoesNotSwallowNextLog(t *testing.T) { + // `td update ... && td log ...` runs within milliseconds; the note is the + // agent's own and must stay a log, not become the update's detail. + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"priority":"P2"}`, `{"priority":"P1"}`, base) + addLogAt(t, db, issue.ID, "ses_a", "bumped because of the outage", models.LogTypeProgress, base.Add(50*time.Millisecond)) - // Add several logs - for i := 0; i < 5; i++ { - if err := db.AddLog(&models.Log{ - IssueID: issue.ID, SessionID: "ses_test", - Message: "log entry", Type: models.LogTypeProgress, - }); err != nil { - t.Fatalf("AddLog failed: %v", err) - } - time.Sleep(time.Millisecond) // ensure distinct timestamps + events := timeline(t, db, issue.ID, 0) + want := "created: Created task (P2)\nupdate: Updated priority: P2 → P1\nlog: bumped because of the outage\n" + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } +} - events, err := db.GetIssueTimeline(issue.ID, 3) - if err != nil { - t.Fatalf("GetIssueTimeline failed: %v", err) +func TestGetIssueTimeline_Updates(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P2","updated_at":"a"}`, `{"priority":"P2","updated_at":"b"}`, base) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P2","description":"x","labels":""}`, + `{"priority":"P1","description":"`+strings.Repeat("long ", 20)+`","labels":"ui"}`, base.Add(time.Minute)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"status":"open"}`, `{"status":"blocked"}`, base.Add(2*time.Minute)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `not json`, `{}`, base.Add(3*time.Minute)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "update: Updated description, labels: (none) → ui, priority: P2 → P1", + "status_change: Updated status: open → blocked", + "update: Updated", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } +} - if len(events) != 3 { - t.Errorf("expected 3 events with limit, got %d", len(events)) +func TestGetIssueTimeline_SkipsUndoneActions(t *testing.T) { + db, issue := newTimelineDB(t) + addActionAt(t, db, issue.ID, "ses_a", models.ActionClose, "", "", issue.CreatedAt.Add(time.Hour)) + if _, err := db.conn.Exec(`UPDATE action_log SET undone = 1 WHERE entity_id = ?`, issue.ID); err != nil { + t.Fatalf("mark undone: %v", err) + } + if events := timeline(t, db, issue.ID, 0); len(events) != 1 { + t.Fatalf("undone close still shown:\n%s", summaries(events)) } } -func TestGetIssueTimeline_SessionAttribution(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) - if err != nil { - t.Fatalf("Initialize failed: %v", err) +func TestGetIssueTimeline_CommentsAndRepeats(t *testing.T) { + db, issue := newTimelineDB(t) + long := strings.Repeat("é", 100) // multi-byte: must not be cut mid-rune + for _, text := range []string{"looks good", long, "line one\nline two", "retry failed", "retry failed", "retry failed"} { + if err := db.AddComment(&models.Comment{IssueID: issue.ID, SessionID: "ses_c", Text: text}); err != nil { + t.Fatalf("AddComment: %v", err) + } + time.Sleep(2 * time.Millisecond) } - defer db.Close() - issue := &models.Issue{Title: "Session test"} - if err := db.CreateIssue(issue); err != nil { - t.Fatalf("CreateIssue failed: %v", err) + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "comment: Comment: looks good", + "comment: Comment / " + long, + "comment: Comment / line one\nline two", + "comment: Comment: retry failed x3", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } +} - if err := db.AddLog(&models.Log{ - IssueID: issue.ID, SessionID: "ses_alpha", - Message: "from alpha", Type: models.LogTypeProgress, +func TestGetIssueTimeline_HandoffsSnapshotsFiles(t *testing.T) { + db, issue := newTimelineDB(t) + if err := db.AddGitSnapshot(&models.GitSnapshot{ + IssueID: issue.ID, Event: "start", CommitSHA: "abc1234567890", Branch: "main", DirtyFiles: 2, }); err != nil { - t.Fatalf("AddLog failed: %v", err) + t.Fatalf("AddGitSnapshot: %v", err) } - - if err := db.AddComment(&models.Comment{ - IssueID: issue.ID, SessionID: "ses_beta", - Text: "from beta", + time.Sleep(2 * time.Millisecond) + if err := db.AddHandoff(&models.Handoff{ + IssueID: issue.ID, SessionID: "ses_a", Done: []string{"a", "b"}, Uncertain: []string{"c"}, }); err != nil { - t.Fatalf("AddComment failed: %v", err) + t.Fatalf("AddHandoff: %v", err) } - - events, err := db.GetIssueTimeline(issue.ID, 0) - if err != nil { - t.Fatalf("GetIssueTimeline failed: %v", err) + time.Sleep(2 * time.Millisecond) + if err := db.LinkFile(issue.ID, "cmd/history.go", models.FileRoleImplementation, "deadbeef"); err != nil { + t.Fatalf("LinkFile: %v", err) } - sessionSeen := make(map[string]bool) - for _, ev := range events { - if ev.SessionID != "" { - sessionSeen[ev.SessionID] = true - } - } - - if !sessionSeen["ses_alpha"] { - t.Error("expected ses_alpha in timeline events") - } - if !sessionSeen["ses_beta"] { - t.Error("expected ses_beta in timeline events") + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "git_snapshot: Git start: abc1234 on main (2 dirty)", + "handoff: Handoff / Done: a; b\nUncertain: c", + "file_link: Linked file: cmd/history.go (implementation)", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) } } -func TestGetIssueTimeline_StatusChangeFromActionLog(t *testing.T) { - dir := t.TempDir() - db, err := Initialize(dir) - if err != nil { - t.Fatalf("Initialize failed: %v", err) - } - defer db.Close() - - issue := &models.Issue{Title: "Status change test"} - if err := db.CreateIssue(issue); err != nil { - t.Fatalf("CreateIssue failed: %v", err) - } - - // Simulate a start action in action_log - if err := db.LogAction(&models.ActionLog{ - SessionID: "ses_test", - ActionType: models.ActionStart, - EntityType: "issue", - EntityID: issue.ID, - NewData: `{"status":"in_progress"}`, - }); err != nil { - t.Fatalf("LogAction failed: %v", err) - } - - events, err := db.GetIssueTimeline(issue.ID, 0) - if err != nil { - t.Fatalf("GetIssueTimeline failed: %v", err) +func TestGetIssueTimeline_Limit(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + for i := 0; i < 5; i++ { + addLogAt(t, db, issue.ID, "ses_a", fmt.Sprintf("log %d", i), models.LogTypeProgress, base.Add(time.Duration(i)*time.Minute)) } - found := false - for _, ev := range events { - if ev.EventType == models.EventStatusChange && ev.Summary == "Status changed to in_progress" { - found = true - break - } - } - if !found { - t.Error("expected status_change event for start action") - for _, ev := range events { - t.Logf(" event: type=%s summary=%q", ev.EventType, ev.Summary) - } + events := timeline(t, db, issue.ID, 2) + if got, want := summaries(events), "log: log 3\nlog: log 4\n"; got != want { + t.Fatalf("limit keeps the most recent events; got:\n%s", got) } } diff --git a/internal/models/history.go b/internal/models/history.go index dff96bd8..4931d524 100644 --- a/internal/models/history.go +++ b/internal/models/history.go @@ -6,7 +6,10 @@ import "time" type EventType string const ( + EventCreated EventType = "created" EventStatusChange EventType = "status_change" + EventUpdate EventType = "update" + EventReview EventType = "review" EventLog EventType = "log" EventHandoff EventType = "handoff" EventGitSnapshot EventType = "git_snapshot" @@ -14,11 +17,15 @@ const ( EventFileLink EventType = "file_link" ) -// TimelineEvent represents a single event in an issue's history +// TimelineEvent represents a single event in an issue's history. +// Timestamps are always UTC. Count is set when a run of identical +// consecutive events (e.g. a retry loop posting the same comment) was +// collapsed into this one; Timestamp is then the first occurrence. type TimelineEvent struct { Timestamp time.Time `json:"timestamp"` EventType EventType `json:"event_type"` Summary string `json:"summary"` Detail string `json:"detail,omitempty"` SessionID string `json:"session_id,omitempty"` + Count int `json:"count,omitempty"` } diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 05981379..d559dea6 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -13,6 +13,7 @@ Complete reference for all `td` commands. | `td create "title" [flags]` | Create issue. Flags: `--type`, `--priority`, `--description`, `--description-file`, `--acceptance`, `--acceptance-file`, `--parent`, `--epic`, `--minor` | | `td list [flags]` | List issues. Flags: `--status`, `--type`, `--priority`, `--epic` | | `td show ` | Display full issue details | +| `td history ` | Chronological timeline of status changes, updates, reviews, logs, handoffs, comments, git snapshots, and linked files. Alias: `td timeline`. Flags: `--limit N` (most recent N), `--json` (UTC timestamps) | | `td update [flags]` | Update fields. Flags: `--title`, `--type`, `--priority`, `--description`, `--description-file`, `--acceptance`, `--acceptance-file`, `--labels` | | `td delete ` | Soft-delete issue | | `td restore ` | Restore soft-deleted issue | From 50b07b85226fea190a5239046fa5153f3e4b3ff9 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Thu, 15 Oct 2026 16:27:05 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(history):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20legacy=20handoffs,=20pairing,=20cleared=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decode the base64-encoded handoff lists written Dec 2025-Feb 2026 in every handoff reader. td history failed on ~32% of issues in a real DB, and td show silently dropped those handoffs. - Pair narration logs by action type and log type: only commands that always write their own log take one, and only of the types they write. A blocker logged after a status update or a decision logged before a delete stays a standalone, typed log. Window tightened to -0.5s..+5s. - Diff the union of previous/new keys so clearing an omitempty field (sprint, labels, description) is shown instead of dropped. - Trim a leading "Rejected: " from narration details, give the synthesized created event the creator session, drop the unreachable review_delete mapping, flag deleted issues (header and deleted_at), and reject a negative --limit. - Tests for each, plus JSON envelope and error-path command tests. Co-Authored-By: Claude Opus 5.5 --- cmd/history.go | 25 ++++++-- cmd/history_test.go | 65 ++++++++++++++++++++ internal/db/activity.go | 41 +++++++++---- internal/db/history.go | 115 +++++++++++++++++++++++++----------- internal/db/history_test.go | 82 ++++++++++++++++++++++++- 5 files changed, 273 insertions(+), 55 deletions(-) diff --git a/cmd/history.go b/cmd/history.go index 165e6272..88142639 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -20,11 +20,13 @@ Merges status changes, field updates, reviews, logs, handoffs, comments, git snapshots, and linked files into a single view. The log a lifecycle command writes (e.g. an approval reason) is shown under its status change. -Times are local in text output and UTC in --json output. +Identical consecutive events (e.g. a retry loop posting the same comment) +collapse into one row with a count. Times are local in text output and UTC +in --json output. Examples: td history td-abc1 # Full timeline - td history td-abc1 --limit 10 # Last 10 events + td history td-abc1 --limit 10 # Most recent 10 rows td history td-abc1 --json # Machine-readable output`, GroupID: "query", Args: cobra.ExactArgs(1), @@ -51,6 +53,9 @@ Examples: } limit, _ := cmd.Flags().GetInt("limit") + if limit < 0 { + return reportFailure(isJSON, output.ErrCodeInvalidInput, fmt.Errorf("--limit must be 0 (no limit) or positive, got %d", limit)) + } events, err := database.GetIssueTimeline(issue.ID, limit) if err != nil { return reportFailure(isJSON, output.ErrCodeDatabaseError, fmt.Errorf("build timeline: %w", err)) @@ -60,15 +65,23 @@ Examples: } if isJSON { - return output.JSON(map[string]any{ + result := map[string]any{ "issue_id": issue.ID, "title": issue.Title, "status": issue.Status, "events": events, - }) + } + if issue.DeletedAt != nil { + result["deleted_at"] = issue.DeletedAt.UTC() + } + return output.JSON(result) } - fmt.Printf("Timeline: %s %q [%s]\n\n", issue.ID, issue.Title, issue.Status) + state := string(issue.Status) + if issue.DeletedAt != nil { + state += ", deleted" + } + fmt.Printf("Timeline: %s %q [%s]\n\n", issue.ID, issue.Title, state) if len(events) == 0 { fmt.Println(" No events recorded.") return nil @@ -138,5 +151,5 @@ func init() { rootCmd.AddCommand(historyCmd) historyCmd.Flags().Bool("json", false, "Machine-readable JSON output") - historyCmd.Flags().Int("limit", 0, "Limit to last N events") + historyCmd.Flags().Int("limit", 0, "Show only the most recent N rows (0 = all)") } diff --git a/cmd/history_test.go b/cmd/history_test.go index f1080e24..a2930053 100644 --- a/cmd/history_test.go +++ b/cmd/history_test.go @@ -1,6 +1,9 @@ package cmd import ( + "encoding/json" + "fmt" + "strings" "testing" "time" @@ -27,3 +30,65 @@ func TestPrintTimeline(t *testing.T) { t.Errorf("got:\n%q\nwant:\n%q", got, want) } } + +func setHistoryFlags(t *testing.T, jsonOut bool, limit string) { + t.Helper() + for name, val := range map[string]string{"json": fmt.Sprint(jsonOut), "limit": limit} { + orig := historyCmd.Flags().Lookup(name).Value.String() + if err := historyCmd.Flags().Set(name, val); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + t.Cleanup(func() { _ = historyCmd.Flags().Set(name, orig) }) + } +} + +func TestHistoryJSON(t *testing.T) { + database, _ := setupReadJSONTest(t) + issue := newReadJSONIssue(t, database, "History JSON", models.StatusOpen, "") + if err := database.DeleteIssue(issue.ID); err != nil { + t.Fatalf("DeleteIssue: %v", err) + } + setHistoryFlags(t, true, "0") + + var runErr error + out := captureStdout(t, func() { runErr = historyCmd.RunE(historyCmd, []string{issue.ID}) }) + if runErr != nil { + t.Fatalf("history: %v", runErr) + } + var got struct { + IssueID string `json:"issue_id"` + DeletedAt *time.Time `json:"deleted_at"` + Events []models.TimelineEvent `json:"events"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + if got.IssueID != issue.ID || got.DeletedAt == nil || len(got.Events) == 0 || got.Events[0].EventType != models.EventCreated { + t.Fatalf("unexpected payload: %s", out) + } +} + +func TestHistoryJSONErrors(t *testing.T) { + database, _ := setupReadJSONTest(t) + issue := newReadJSONIssue(t, database, "History errors", models.StatusOpen, "") + + for _, tc := range []struct { + name, id, limit, code string + }{ + {"not found", "td-doesnotexist", "0", "not_found"}, + {"negative limit", issue.ID, "-1", "invalid_input"}, + } { + t.Run(tc.name, func(t *testing.T) { + setHistoryFlags(t, true, tc.limit) + var runErr error + out := captureStdout(t, func() { runErr = historyCmd.RunE(historyCmd, []string{tc.id}) }) + requireSilentFailure(t, runErr) + var env struct { + Error struct{ Code string } `json:"error"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil || env.Error.Code != tc.code { + t.Fatalf("want %s envelope, got %q (%v)", tc.code, out, err) + } + }) + } +} diff --git a/internal/db/activity.go b/internal/db/activity.go index 78b06427..0b61e101 100644 --- a/internal/db/activity.go +++ b/internal/db/activity.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/base64" "encoding/json" "fmt" "time" @@ -265,6 +266,22 @@ func (db *DB) AddHandoff(handoff *models.Handoff) error { }) } +// decodeHandoffList decodes a handoff list column. Handoffs written between +// roughly Dec 2025 and Feb 2026 stored the JSON base64-encoded (a []byte +// round-trip), e.g. "W10=" for "[]"; those rows are decoded transparently. +func decodeHandoffList(raw string, dst *[]string) error { + err := json.Unmarshal([]byte(raw), dst) + if err == nil { + return nil + } + if decoded, b64err := base64.StdEncoding.DecodeString(raw); b64err == nil { + if json.Unmarshal(decoded, dst) == nil { + return nil + } + } + return err +} + // GetLatestHandoff retrieves the latest handoff for an issue func (db *DB) GetLatestHandoff(issueID string) (*models.Handoff, error) { var handoff models.Handoff @@ -285,16 +302,16 @@ func (db *DB) GetLatestHandoff(issueID string) (*models.Handoff, error) { return nil, err } - if err := json.Unmarshal([]byte(doneJSON), &handoff.Done); err != nil { + if err := decodeHandoffList(doneJSON, &handoff.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &handoff.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &handoff.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &handoff.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &handoff.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &handoff.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &handoff.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } @@ -322,16 +339,16 @@ func (db *DB) GetHandoffs(issueID string) ([]models.Handoff, error) { if err != nil { return nil, fmt.Errorf("failed to scan handoff row: %w", err) } - if err := json.Unmarshal([]byte(doneJSON), &h.Done); err != nil { + if err := decodeHandoffList(doneJSON, &h.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &h.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &h.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &h.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &h.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &h.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &h.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } handoffs = append(handoffs, h) @@ -369,16 +386,16 @@ func (db *DB) GetRecentHandoffs(limit int, since time.Time) ([]models.Handoff, e if err != nil { return nil, fmt.Errorf("failed to scan handoff row: %w", err) } - if err := json.Unmarshal([]byte(doneJSON), &h.Done); err != nil { + if err := decodeHandoffList(doneJSON, &h.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &h.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &h.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &h.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &h.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &h.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &h.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } handoffs = append(handoffs, h) diff --git a/internal/db/history.go b/internal/db/history.go index 6e66507a..06b5a92e 100644 --- a/internal/db/history.go +++ b/internal/db/history.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "sort" "strings" "time" @@ -12,15 +13,40 @@ import ( "github.com/marcus/td/internal/models" ) -// Lifecycle commands write an action_log row and a log entry for the same -// transition, a few seconds apart (start takes a git snapshot in between). -// A log from the same session inside this window is folded into the -// transition's event as its detail instead of appearing as a separate event. +// Lifecycle commands write an action_log row and then a log entry narrating +// it, usually within a quarter second (start takes a git snapshot in +// between, so up to a few seconds). That log is folded into the action's +// event as its detail instead of appearing as a separate event. The small +// negative slack only absorbs clock rounding between the two writes. const ( - timelinePairBefore = 2 * time.Second - timelinePairAfter = 10 * time.Second + timelinePairBefore = 500 * time.Millisecond + timelinePairAfter = 5 * time.Second ) +// narratingLogTypes lists the actions whose command always writes its own +// log, and the log types it writes. Only these pair: update, delete, and +// restore write no log, so a note logged next to them is the user's own. +var narratingLogTypes = map[models.ActionType][]models.LogType{ + models.ActionStart: {models.LogTypeProgress}, + models.ActionReview: {models.LogTypeProgress}, + models.ActionApprove: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReject: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionClose: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionCloseAfterReview: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReviewApprove: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReviewChangesRequested: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionBlock: {models.LogTypeBlocker, models.LogTypeProgress}, + models.ActionUnblock: {models.LogTypeProgress}, + models.ActionReopen: {models.LogTypeProgress}, +} + +// timelineAction is an action event plus the log types its command narrates +// itself with (none for actions that write no log). +type timelineAction struct { + event models.TimelineEvent + logTypes []models.LogType +} + // commentSummaryLen is the longest comment, in runes, shown inline in the // summary rather than as detail. const commentSummaryLen = 80 @@ -40,7 +66,7 @@ func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEven if err != nil { return nil, fmt.Errorf("query action log: %w", err) } - var actionEvents []models.TimelineEvent + var timelineActions []timelineAction hasCreate := false for _, a := range actions { ev, ok := issueActionToEvent(a) @@ -50,23 +76,24 @@ func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEven if ev.EventType == models.EventCreated { hasCreate = true } - actionEvents = append(actionEvents, ev) + timelineActions = append(timelineActions, timelineAction{ev, narratingLogTypes[a.ActionType]}) } // Issues created without an action_log row (imports, older data) still // get a starting point. if !hasCreate && !issue.CreatedAt.IsZero() { - actionEvents = append([]models.TimelineEvent{{ + timelineActions = append([]timelineAction{{event: models.TimelineEvent{ Timestamp: issue.CreatedAt, EventType: models.EventCreated, Summary: createdSummary(string(issue.Type), string(issue.Priority)), - }}, actionEvents...) + SessionID: issue.CreatorSession, + }}}, timelineActions...) } logs, err := db.GetLogs(issueID, 0) if err != nil { return nil, fmt.Errorf("query logs: %w", err) } - events := append(actionEvents, foldLogsIntoActions(actionEvents, logs)...) + events := foldLogsIntoActions(timelineActions, logs) handoffs, err := db.GetHandoffs(issueID) if err != nil { @@ -168,29 +195,28 @@ func collapseRepeats(events []models.TimelineEvent) []models.TimelineEvent { return out } -// foldLogsIntoActions attaches each lifecycle log to the action event it -// narrates and returns the remaining logs as events of their own. A log -// pairs with an action from the same session within the pairing window; -// closest pairs are matched first, and each side pairs at most once. A log -// that only repeats (a prefix of) the action's summary, such as "Approved" -// for "Approved and closed", is dropped rather than shown twice. -func foldLogsIntoActions(actions []models.TimelineEvent, logs []models.Log) []models.TimelineEvent { +// foldLogsIntoActions attaches each narrating log to the action it narrates +// and returns all action events followed by the remaining logs as events of +// their own. A log pairs with an action from the same session, of a type that +// action's command writes, inside the pairing window; closest pairs are +// matched first, and each side pairs at most once. A paired log that only +// repeats the summary ("Approved" for "Approved and closed") is dropped, and +// a leading ": " is trimmed ("Rejected: reason" becomes "reason"). +func foldLogsIntoActions(actions []timelineAction, logs []models.Log) []models.TimelineEvent { type candidate struct { action, log int gap time.Duration } var candidates []candidate for ai, a := range actions { - // Only transitions and reviews narrate themselves with a log; an - // update would otherwise swallow a note logged right after it. - if (a.EventType != models.EventStatusChange && a.EventType != models.EventReview) || a.SessionID == "" { + if len(a.logTypes) == 0 || a.event.SessionID == "" { continue } for li, l := range logs { - if l.SessionID != a.SessionID { + if l.SessionID != a.event.SessionID || !slices.Contains(a.logTypes, l.Type) { continue } - gap := l.Timestamp.Sub(a.Timestamp) + gap := l.Timestamp.Sub(a.event.Timestamp) if gap < -timelinePairBefore || gap > timelinePairAfter { continue } @@ -199,6 +225,10 @@ func foldLogsIntoActions(actions []models.TimelineEvent, logs []models.Log) []mo } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].gap < candidates[j].gap }) + events := make([]models.TimelineEvent, 0, len(actions)+len(logs)) + for _, a := range actions { + events = append(events, a.event) + } actionPaired := make([]bool, len(actions)) logPaired := make([]bool, len(logs)) for _, c := range candidates { @@ -206,13 +236,9 @@ func foldLogsIntoActions(actions []models.TimelineEvent, logs []models.Log) []mo continue } actionPaired[c.action], logPaired[c.log] = true, true - msg := strings.TrimSpace(logs[c.log].Message) - if !strings.HasPrefix(strings.ToLower(actions[c.action].Summary), strings.ToLower(msg)) { - actions[c.action].Detail = msg - } + events[c.action].Detail = narrationDetail(events[c.action].Summary, logs[c.log].Message) } - var rest []models.TimelineEvent for li, l := range logs { if logPaired[li] { continue @@ -221,14 +247,27 @@ func foldLogsIntoActions(actions []models.TimelineEvent, logs []models.Log) []mo if l.Type != "" && l.Type != models.LogTypeProgress { summary = fmt.Sprintf("[%s] %s", l.Type, l.Message) } - rest = append(rest, models.TimelineEvent{ + events = append(events, models.TimelineEvent{ Timestamp: l.Timestamp, EventType: models.EventLog, Summary: summary, SessionID: l.SessionID, }) } - return rest + return events +} + +// narrationDetail returns what a narrating log adds to its action's summary. +func narrationDetail(summary, msg string) string { + msg = strings.TrimSpace(msg) + lowerSummary, lowerMsg := strings.ToLower(summary), strings.ToLower(msg) + if strings.HasPrefix(lowerSummary, lowerMsg) { + return "" + } + if strings.HasPrefix(lowerMsg, lowerSummary+":") { + return strings.TrimSpace(msg[len(summary)+1:]) + } + return msg } // getGitSnapshots returns all git snapshots for an issue. @@ -298,7 +337,6 @@ var actionSummaries = map[models.ActionType]struct { models.ActionRestore: {models.EventStatusChange, "Restored"}, models.ActionReviewApprove: {models.EventReview, "Review recorded: approved"}, models.ActionReviewChangesRequested: {models.EventReview, "Review recorded: changes requested"}, - models.ActionReviewDelete: {models.EventReview, "Review record removed"}, } func issueActionToEvent(a models.ActionLog) (models.TimelineEvent, bool) { @@ -345,10 +383,19 @@ func issueUpdateToEvent(a models.ActionLog, ev models.TimelineEvent) (models.Tim return ev, true } + // Union of both key sets: omitempty fields vanish from new_data when + // they are cleared. var keys []string - for k := range next { - if !timelineIgnoredFields[k] && !reflect.DeepEqual(prev[k], next[k]) { - keys = append(keys, k) + seen := map[string]bool{} + for _, m := range []map[string]any{prev, next} { + for k := range m { + if seen[k] || timelineIgnoredFields[k] { + continue + } + seen[k] = true + if !reflect.DeepEqual(prev[k], next[k]) { + keys = append(keys, k) + } } } if len(keys) == 0 { diff --git a/internal/db/history_test.go b/internal/db/history_test.go index b338b35f..e3808355 100644 --- a/internal/db/history_test.go +++ b/internal/db/history_test.go @@ -166,6 +166,69 @@ func TestGetIssueTimeline_UpdateDoesNotSwallowNextLog(t *testing.T) { } } +func TestGetIssueTimeline_OnlyNarratingActionsTakeLogs(t *testing.T) { + // Agents chain commands within milliseconds. A typed note logged next to + // an action whose command writes no log of that type is the agent's own + // and must stay a standalone log with its type. + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + // td update --status blocked; td log --blocker ... + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"status":"open"}`, `{"status":"blocked"}`, base) + addLogAt(t, db, issue.ID, "ses_a", "waiting on API keys", models.LogTypeBlocker, base.Add(30*time.Millisecond)) + + // td log --decision ...; td delete (the log comes first). + d := base.Add(time.Minute) + addLogAt(t, db, issue.ID, "ses_a", "use approach B", models.LogTypeDecision, d) + addActionAt(t, db, issue.ID, "ses_a", models.ActionDelete, "", "", d.Add(20*time.Millisecond)) + + // td start; td log --decision ...: start narrates with progress only. + st := base.Add(2 * time.Minute) + addActionAt(t, db, issue.ID, "ses_a", models.ActionStart, "", "", st) + addLogAt(t, db, issue.ID, "ses_a", "going with B", models.LogTypeDecision, st.Add(10*time.Millisecond)) + + // A narrating log written over half a second before its action is not + // its narration. + rj := base.Add(3 * time.Minute) + addLogAt(t, db, issue.ID, "ses_a", "early note", models.LogTypeProgress, rj.Add(-2*time.Second)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionReject, "", "", rj) + addLogAt(t, db, issue.ID, "ses_a", "Rejected: missing tests", models.LogTypeProgress, rj.Add(100*time.Millisecond)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "status_change: Updated status: open → blocked", + "log: [blocker] waiting on API keys", + "log: [decision] use approach B", + "status_change: Deleted", + "status_change: Started work", + "log: [decision] going with B", + "log: early note", + "status_change: Rejected / missing tests", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_LegacyBase64Handoff(t *testing.T) { + // Handoffs from Dec 2025-Feb 2026 stored their lists base64-encoded. + db, issue := newTimelineDB(t) + if _, err := db.conn.Exec(` + INSERT INTO handoffs (id, issue_id, session_id, done, remaining, decisions, uncertain, timestamp) + VALUES ('ho-legacy', ?, 'ses_a', 'WyJzaGlwcGVkIl0=', 'W10=', 'W10=', 'W10=', ?)`, + issue.ID, issue.CreatedAt.Add(time.Hour), + ); err != nil { + t.Fatalf("insert legacy handoff: %v", err) + } + + events := timeline(t, db, issue.ID, 0) + if got, want := summaries(events), "created: Created task (P2)\nhandoff: Handoff / Done: shipped\n"; got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + func TestGetIssueTimeline_Updates(t *testing.T) { db, issue := newTimelineDB(t) base := issue.CreatedAt.Add(time.Hour) @@ -173,8 +236,11 @@ func TestGetIssueTimeline_Updates(t *testing.T) { addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"priority":"P2","updated_at":"a"}`, `{"priority":"P2","updated_at":"b"}`, base) addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, - `{"priority":"P2","description":"x","labels":""}`, - `{"priority":"P1","description":"`+strings.Repeat("long ", 20)+`","labels":"ui"}`, base.Add(time.Minute)) + `{"priority":"P2","description":"x","labels":["api"]}`, + `{"priority":"P1","description":"`+strings.Repeat("long ", 20)+`","labels":["api","ui"]}`, base.Add(time.Minute)) + // Clearing an omitempty field drops its key from new_data entirely. + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P1","sprint":"s1"}`, `{"priority":"P1"}`, base.Add(90*time.Second)) addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"status":"open"}`, `{"status":"blocked"}`, base.Add(2*time.Minute)) addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `not json`, `{}`, base.Add(3*time.Minute)) @@ -182,7 +248,8 @@ func TestGetIssueTimeline_Updates(t *testing.T) { events := timeline(t, db, issue.ID, 0) want := strings.Join([]string{ "created: Created task (P2)", - "update: Updated description, labels: (none) → ui, priority: P2 → P1", + "update: Updated description, labels, priority: P2 → P1", + "update: Updated sprint: s1 → (none)", "status_change: Updated status: open → blocked", "update: Updated", "", @@ -269,4 +336,13 @@ func TestGetIssueTimeline_Limit(t *testing.T) { if got, want := summaries(events), "log: log 3\nlog: log 4\n"; got != want { t.Fatalf("limit keeps the most recent events; got:\n%s", got) } + + // A collapsed run counts as one row. + for i := 0; i < 3; i++ { + addLogAt(t, db, issue.ID, "ses_a", "retry", models.LogTypeProgress, base.Add(time.Hour+time.Duration(i)*time.Second)) + } + events = timeline(t, db, issue.ID, 2) + if got, want := summaries(events), "log: log 4\nlog: retry x3\n"; got != want { + t.Fatalf("limit after collapsing; got:\n%s", got) + } } From c11a1b4928815bf897ce819477c457cd234d7b5a Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Thu, 15 Oct 2026 16:32:34 -0700 Subject: [PATCH 4/4] fix(db): read logs with a NULL id; note td show fixes in changelog A log row left with a NULL id by the Jan 2026 text-ID migration made GetLogs fail for its issue; td history errored and td show silently dropped every log. COALESCE the id. Also clarify the narration-pairing and ASCII-slicing comments from review round 2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ internal/db/activity.go | 4 +++- internal/db/history.go | 11 ++++++++--- internal/db/history_test.go | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cbccd8d..0ab192ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to td are documented in this file. - **`td history ` (alias `td timeline`) shows an issue's whole life in order:** creation, status changes, field updates (`priority: P2 → P1`), review records, logs, handoffs, comments, git snapshots, and linked files. The log a lifecycle command writes, such as an approval's reason, appears under its status change instead of as a duplicate line. Identical consecutive events, such as a retry loop posting the same comment, collapse into one with a count. `--limit N` keeps the most recent N events; `--json` returns the events with UTC timestamps. +### Bug Fixes + +- **`td show` no longer drops handoffs or logs on older issues.** Handoffs written between December 2025 and February 2026 stored their lists base64-encoded, and failed to decode, so `td show` silently omitted them (938 rows in td's own project). They now decode in every handoff reader. A log row left with a NULL ID by the January 2026 text-ID migration made the whole issue's log query fail; it now reads. + ## [v0.67.0] - 2026-10-15 ### Sync Server diff --git a/internal/db/activity.go b/internal/db/activity.go index 0b61e101..1c17c093 100644 --- a/internal/db/activity.go +++ b/internal/db/activity.go @@ -58,7 +58,9 @@ func (db *DB) GetLogs(issueID string, limit int) ([]models.Log, error) { // Get logs that are either: // 1. Directly assigned to this issue (issue_id = ?) // 2. Work session logs (issue_id = '') from sessions where this issue is tagged - query := `SELECT CAST(l.id AS TEXT), l.issue_id, l.session_id, l.work_session_id, l.message, l.type, l.timestamp + // COALESCE: a log row left with a NULL id by the Jan 2026 text-ID + // migration would otherwise fail the scan and lose every log for the issue. + query := `SELECT COALESCE(CAST(l.id AS TEXT), ''), l.issue_id, l.session_id, l.work_session_id, l.message, l.type, l.timestamp FROM logs l WHERE l.issue_id = ? OR (l.issue_id = '' AND l.work_session_id IN ( diff --git a/internal/db/history.go b/internal/db/history.go index 06b5a92e..8f003e1f 100644 --- a/internal/db/history.go +++ b/internal/db/history.go @@ -23,9 +23,12 @@ const ( timelinePairAfter = 5 * time.Second ) -// narratingLogTypes lists the actions whose command always writes its own -// log, and the log types it writes. Only these pair: update, delete, and -// restore write no log, so a note logged next to them is the user's own. +// narratingLogTypes lists the actions whose CLI command writes its own log, +// and the log types it writes. Only these pair: update, delete, and restore +// write no log, so a note logged next to them is the user's own. Some paths +// (cascades, programmatic closes) record these actions without a log; a +// progress note logged within the window of one of those would be folded +// in as its detail, which still shows it. var narratingLogTypes = map[models.ActionType][]models.LogType{ models.ActionStart: {models.LogTypeProgress}, models.ActionReview: {models.LogTypeProgress}, @@ -264,6 +267,8 @@ func narrationDetail(summary, msg string) string { if strings.HasPrefix(lowerSummary, lowerMsg) { return "" } + // Summaries are ASCII, so their byte length is the same before and + // after lower-casing and slicing msg by it is safe. if strings.HasPrefix(lowerMsg, lowerSummary+":") { return strings.TrimSpace(msg[len(summary)+1:]) } diff --git a/internal/db/history_test.go b/internal/db/history_test.go index e3808355..eceb1c3a 100644 --- a/internal/db/history_test.go +++ b/internal/db/history_test.go @@ -229,6 +229,23 @@ func TestGetIssueTimeline_LegacyBase64Handoff(t *testing.T) { } } +func TestGetIssueTimeline_NullLogID(t *testing.T) { + // One real log row was left with a NULL id by the text-ID migration. + db, issue := newTimelineDB(t) + if _, err := db.conn.Exec(` + INSERT INTO logs (id, issue_id, session_id, work_session_id, message, type, timestamp) + VALUES (NULL, ?, 'ses_a', '', 'orphaned id', 'progress', ?)`, + issue.ID, issue.CreatedAt.Add(time.Hour), + ); err != nil { + t.Skipf("schema rejects NULL log ids: %v", err) + } + + events := timeline(t, db, issue.ID, 0) + if got, want := summaries(events), "created: Created task (P2)\nlog: orphaned id\n"; got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + func TestGetIssueTimeline_Updates(t *testing.T) { db, issue := newTimelineDB(t) base := issue.CreatedAt.Add(time.Hour)