Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to td are documented in this file.

## [Unreleased]

### CLI

- **`td history <id>` (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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` |
| View an issue's timeline | `td history <id>` |
| Capture handoff state | `td handoff <id> --done "..." --remaining "..."` |
| Submit for review | `td review <id>` |
| See reviewable issues | `td reviewable` |
Expand Down
155 changes: 155 additions & 0 deletions cmd/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"fmt"
"strings"

"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 <issue-id>",
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 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.

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 # Most recent 10 rows
td history td-abc1 --json # Machine-readable output`,
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 <issue-id>"); err != nil {
return reportFailure(isJSON, output.ErrCodeInvalidInput, err)
}

database, err := db.Open(getBaseDir())
if err != nil {
return reportFailure(isJSON, output.ErrCodeDatabaseError, err)
}
defer func() { _ = database.Close() }()

issue, err := database.GetIssue(issueID)
if err != nil {
return reportFailure(isJSON, output.ErrCodeNotFound, err)
}

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))
}
if events == nil {
events = []models.TimelineEvent{}
}

if isJSON {
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)
}

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
}
printTimeline(events)
return nil
},
}

// 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() {
rootCmd.AddCommand(historyCmd)

historyCmd.Flags().Bool("json", false, "Machine-readable JSON output")
historyCmd.Flags().Int("limit", 0, "Show only the most recent N rows (0 = all)")
}
94 changes: 94 additions & 0 deletions cmd/history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package cmd

import (
"encoding/json"
"fmt"
"strings"
"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)
}
}

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)
}
})
}
}
45 changes: 32 additions & 13 deletions internal/db/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package db

import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"time"
Expand Down Expand Up @@ -57,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 (
Expand Down Expand Up @@ -265,6 +268,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
Expand All @@ -285,16 +304,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)
}

Expand Down Expand Up @@ -322,16 +341,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)
Expand Down Expand Up @@ -369,16 +388,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)
Expand Down
Loading
Loading