Skip to content

Commit 0003ce7

Browse files
feat: Track created and updated timestamps on issues
1 parent c0c9dd5 commit 0003ce7

10 files changed

+301
-12
lines changed

format_issue.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"strings"
7+
"time"
78
)
89

910
// PrintIssueDetails prints full issue details including ID, Title, Status,
@@ -14,6 +15,12 @@ func PrintIssueDetails(w io.Writer, issue *Issue, store *Store) error {
1415
fmt.Fprintf(&b, "\033[1m\033[38;5;5mID\033[0m %s\n", store.FormatID(issue.ID))
1516
fmt.Fprintf(&b, "\033[1m\033[38;5;5mTitle\033[0m %s\n", issue.Title)
1617
fmt.Fprintf(&b, "\033[1m\033[38;5;5mStatus\033[0m %s\n", issue.Status)
18+
if !issue.CreatedAt.IsZero() {
19+
fmt.Fprintf(&b, "\033[1m\033[38;5;5mCreated\033[0m %s\n", issue.CreatedAt.Format(time.DateTime))
20+
}
21+
if !issue.UpdatedAt.IsZero() {
22+
fmt.Fprintf(&b, "\033[1m\033[38;5;5mUpdated\033[0m %s\n", issue.UpdatedAt.Format(time.DateTime))
23+
}
1724
if len(issue.DependsOn) > 0 || len(issue.Blocks) > 0 || len(issue.Comments) > 0 {
1825
fmt.Fprintln(&b)
1926
}

format_issue_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,13 @@ func TestPrintIssueDetails_Whitespace(t *testing.T) {
358358
t.Errorf("expected output to start with newline")
359359
}
360360

361-
// Should have blank line between Status and Depends on
362-
if !strings.Contains(stripped, "Status open\n\nDepends on") {
363-
t.Errorf("expected blank line between Status and Depends on, got: %s", stripped)
361+
// Should have timestamps followed by blank line before Depends on
362+
if !strings.Contains(stripped, "Updated") {
363+
t.Errorf("expected Updated timestamp, got: %s", stripped)
364+
}
365+
// Check for blank line before Depends on (after timestamps)
366+
if !strings.Contains(stripped, "\n\nDepends on") {
367+
t.Errorf("expected blank line before Depends on, got: %s", stripped)
364368
}
365369

366370
// Should have blank line between Depends on and Comments

store_core.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"os"
5+
"time"
56

67
"github.com/goccy/go-yaml"
78
)
@@ -14,12 +15,14 @@ type Store struct {
1415

1516
// Issue represents a single issue
1617
type Issue struct {
17-
ID string `yaml:"id"`
18-
Title string `yaml:"title"`
19-
Status string `yaml:"status"`
20-
DependsOn []string `yaml:"depends_on,omitempty"`
21-
Blocks []string `yaml:"blocks,omitempty"`
22-
Comments []string `yaml:"comments,omitempty"`
18+
ID string `yaml:"id"`
19+
Title string `yaml:"title"`
20+
Status string `yaml:"status"`
21+
CreatedAt time.Time `yaml:"created_at"`
22+
UpdatedAt time.Time `yaml:"updated_at"`
23+
DependsOn []string `yaml:"depends_on,omitempty"`
24+
Blocks []string `yaml:"blocks,omitempty"`
25+
Comments []string `yaml:"comments,omitempty"`
2326
}
2427

2528
// NewStore creates a new store with defaults
@@ -59,3 +62,11 @@ func (s *Store) Save(filePath string) error {
5962

6063
return os.WriteFile(filePath, data, 0o600)
6164
}
65+
66+
// touch updates the UpdatedAt timestamp for one or more issues
67+
func (s *Store) touch(issues ...*Issue) {
68+
now := time.Now()
69+
for _, issue := range issues {
70+
issue.UpdatedAt = now
71+
}
72+
}

store_core_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,55 @@ func TestStoreSaveOrderSorted(t *testing.T) {
181181
t.Logf("Content:\n%s", contentStr)
182182
}
183183
}
184+
185+
func TestStoreLoadOldYAML_ZeroTimestamps(t *testing.T) {
186+
// Test that old YAML files (without timestamps) load with zero values
187+
store := NewStore()
188+
issue := &Issue{
189+
ID: "test-123",
190+
Title: "Old issue",
191+
Status: "open",
192+
// CreatedAt and UpdatedAt not set - simulate old YAML
193+
}
194+
store.Issues[issue.ID] = issue
195+
196+
if !issue.CreatedAt.IsZero() {
197+
t.Error("Old issue CreatedAt should be zero")
198+
}
199+
if !issue.UpdatedAt.IsZero() {
200+
t.Error("Old issue UpdatedAt should be zero")
201+
}
202+
}
203+
204+
func TestStoreLoadSave_PreservesTimestamps(t *testing.T) {
205+
store := NewStore()
206+
issue, _ := store.AddIssue("New issue")
207+
208+
// Save and reload
209+
tmpFile := filepath.Join(t.TempDir(), "test.yaml")
210+
err := store.Save(tmpFile)
211+
if err != nil {
212+
t.Fatalf("Save() failed: %v", err)
213+
}
214+
215+
loaded, err := LoadStore(tmpFile)
216+
if err != nil {
217+
t.Fatalf("LoadStore() failed: %v", err)
218+
}
219+
220+
loadedIssue := loaded.Issues[issue.ID]
221+
if loadedIssue.CreatedAt.IsZero() {
222+
t.Error("Loaded issue CreatedAt should not be zero")
223+
}
224+
if loadedIssue.UpdatedAt.IsZero() {
225+
t.Error("Loaded issue UpdatedAt should not be zero")
226+
}
227+
228+
// Timestamps should match original (within precision)
229+
if !loadedIssue.CreatedAt.Equal(issue.CreatedAt) {
230+
t.Error("CreatedAt should survive round-trip")
231+
}
232+
if !loadedIssue.UpdatedAt.Equal(issue.UpdatedAt) {
233+
t.Error("UpdatedAt should survive round-trip")
234+
}
235+
}

store_crud.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"sort"
6+
"time"
67
)
78

89
// AddIssue creates a new issue with a unique ID and adds it to the store
@@ -23,10 +24,13 @@ func (s *Store) AddIssue(title string) (*Issue, error) {
2324
}
2425
}
2526

27+
now := time.Now()
2628
issue := &Issue{
27-
ID: id,
28-
Title: title,
29-
Status: "open",
29+
ID: id,
30+
Title: title,
31+
Status: "open",
32+
CreatedAt: now,
33+
UpdatedAt: now,
3034
}
3135

3236
s.Issues[id] = issue
@@ -112,5 +116,6 @@ func (s *Store) UpdateIssueTitle(id, title string) error {
112116
return err
113117
}
114118
issue.Title = title
119+
s.touch(issue)
115120
return nil
116121
}

store_crud_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"strings"
55
"testing"
6+
"time"
67
)
78

89
func TestStoreAddIssue(t *testing.T) {
@@ -198,3 +199,58 @@ func TestStoreResolveIssueID_EmptyPrefix(t *testing.T) {
198199
t.Errorf("expected 'abc123', got '%s'", id)
199200
}
200201
}
202+
203+
func TestStoreAddIssue_Timestamps(t *testing.T) {
204+
store := NewStore()
205+
206+
before := time.Now()
207+
issue, err := store.AddIssue("Test issue")
208+
after := time.Now()
209+
210+
if err != nil {
211+
t.Fatalf("AddIssue() failed: %v", err)
212+
}
213+
214+
// CreatedAt should be set
215+
if issue.CreatedAt.IsZero() {
216+
t.Error("CreatedAt should not be zero")
217+
}
218+
219+
// UpdatedAt should be set
220+
if issue.UpdatedAt.IsZero() {
221+
t.Error("UpdatedAt should not be zero")
222+
}
223+
224+
// Both should equal on creation
225+
if !issue.CreatedAt.Equal(issue.UpdatedAt) {
226+
t.Errorf("CreatedAt and UpdatedAt should be equal on creation, got CreatedAt=%v, UpdatedAt=%v",
227+
issue.CreatedAt, issue.UpdatedAt)
228+
}
229+
230+
// Timestamps should be within test execution window
231+
if issue.CreatedAt.Before(before) || issue.CreatedAt.After(after) {
232+
t.Errorf("CreatedAt should be between %v and %v, got %v", before, after, issue.CreatedAt)
233+
}
234+
}
235+
236+
func TestStoreUpdateIssueTitle_UpdatesTimestamp(t *testing.T) {
237+
store := NewStore()
238+
issue, _ := store.AddIssue("Original")
239+
240+
originalCreated := issue.CreatedAt
241+
originalUpdated := issue.UpdatedAt
242+
time.Sleep(10 * time.Millisecond) // Ensure timestamp differs
243+
244+
err := store.UpdateIssueTitle(issue.ID, "New title")
245+
if err != nil {
246+
t.Fatalf("UpdateIssueTitle() failed: %v", err)
247+
}
248+
249+
if !issue.UpdatedAt.After(originalUpdated) {
250+
t.Errorf("UpdatedAt should be after original, got original=%v, new=%v", originalUpdated, issue.UpdatedAt)
251+
}
252+
253+
if !issue.CreatedAt.Equal(originalCreated) {
254+
t.Error("CreatedAt should not change on update")
255+
}
256+
}

store_lifecycle.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ func (s *Store) AddComment(id, comment string) error {
99
return err
1010
}
1111
issue.Comments = append(issue.Comments, comment)
12+
s.touch(issue)
1213
return nil
1314
}
1415

@@ -22,6 +23,7 @@ func (s *Store) CloseIssue(id, reason string) error {
2223
if reason != "" {
2324
issue.Comments = append(issue.Comments, fmt.Sprintf("Closed with reason: %s", reason))
2425
}
26+
s.touch(issue)
2527
return nil
2628
}
2729

@@ -32,6 +34,7 @@ func (s *Store) ReopenIssue(id string) error {
3234
return fmt.Errorf("issue not found: %s", id)
3335
}
3436
issue.Status = "open"
37+
s.touch(issue)
3538
return nil
3639
}
3740

store_lifecycle_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"testing"
5+
"time"
56
)
67

78
func TestStoreCloseIssue(t *testing.T) {
@@ -165,3 +166,52 @@ func TestStoreDeleteIssue_NotFound(t *testing.T) {
165166
t.Errorf("expected error '%s', got '%s'", expectedErr, err.Error())
166167
}
167168
}
169+
170+
func TestStoreAddComment_UpdatesTimestamp(t *testing.T) {
171+
store := NewStore()
172+
issue, _ := store.AddIssue("Test")
173+
originalUpdated := issue.UpdatedAt
174+
time.Sleep(10 * time.Millisecond)
175+
176+
err := store.AddComment(issue.ID, "Comment")
177+
if err != nil {
178+
t.Fatalf("AddComment() failed: %v", err)
179+
}
180+
181+
if !issue.UpdatedAt.After(originalUpdated) {
182+
t.Errorf("UpdatedAt should be after original")
183+
}
184+
}
185+
186+
func TestStoreCloseIssue_UpdatesTimestamp(t *testing.T) {
187+
store := NewStore()
188+
issue, _ := store.AddIssue("Test")
189+
originalUpdated := issue.UpdatedAt
190+
time.Sleep(10 * time.Millisecond)
191+
192+
err := store.CloseIssue(issue.ID, "")
193+
if err != nil {
194+
t.Fatalf("CloseIssue() failed: %v", err)
195+
}
196+
197+
if !issue.UpdatedAt.After(originalUpdated) {
198+
t.Errorf("UpdatedAt should be after original")
199+
}
200+
}
201+
202+
func TestStoreReopenIssue_UpdatesTimestamp(t *testing.T) {
203+
store := NewStore()
204+
issue, _ := store.AddIssue("Test")
205+
_ = store.CloseIssue(issue.ID, "")
206+
originalUpdated := issue.UpdatedAt
207+
time.Sleep(10 * time.Millisecond)
208+
209+
err := store.ReopenIssue(issue.ID)
210+
if err != nil {
211+
t.Fatalf("ReopenIssue() failed: %v", err)
212+
}
213+
214+
if !issue.UpdatedAt.After(originalUpdated) {
215+
t.Errorf("UpdatedAt should be after original")
216+
}
217+
}

store_relationships.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func (s *Store) AddDependency(issueID, dependsOnID string) error {
1414

1515
issue.DependsOn = append(issue.DependsOn, dependsOnID)
1616
blocker.Blocks = append(blocker.Blocks, issueID)
17+
s.touch(issue, blocker)
1718
return nil
1819
}
1920

@@ -31,6 +32,7 @@ func (s *Store) AddBlocker(issueID, blockedID string) error {
3132

3233
issue.Blocks = append(issue.Blocks, blockedID)
3334
blocked.DependsOn = append(blocked.DependsOn, issueID)
35+
s.touch(issue, blocked)
3436
return nil
3537
}
3638

@@ -64,6 +66,7 @@ func (s *Store) RemoveDependency(issueID, dependsOnID string) error {
6466
}
6567
blocker.Blocks = newBlocks
6668

69+
s.touch(issue, blocker)
6770
return nil
6871
}
6972

@@ -97,5 +100,6 @@ func (s *Store) RemoveBlocker(issueID, blockedID string) error {
97100
}
98101
blocked.DependsOn = newDeps
99102

103+
s.touch(issue, blocked)
100104
return nil
101105
}

0 commit comments

Comments
 (0)