Skip to content

Commit 0130186

Browse files
feat: Issues are now sorted by timestamps
1 parent 8ddd977 commit 0130186

File tree

4 files changed

+205
-4
lines changed

4 files changed

+205
-4
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ The current unique part of an ID is always underlined for easy spotting.
142142

143143
By default, issues will be given a prefix, like `mint-m3f`. But you can set the prefix to nothing and just use the nanoID for the issue IDs, like `m3f`.
144144

145+
### Issue sorting
146+
147+
When you run `mint list`, issues are automatically sorted by timestamps:
148+
149+
- **Ready and blocked issues**: Sorted by creation date, with the newest issues at the top
150+
- **Closed issues**: Sorted by last update date, with the most recently updated at the top
151+
145152
## Issue storage
146153

147154
Issues are stored as plain text in a single YAML file (`mint-issues.yaml`), and I recommend tracking it in version control. If an issue file isn't found, it's created when the first issue is added.

cmd_list.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"sort"
78

89
"github.com/urfave/cli/v3"
910
)
@@ -58,6 +59,15 @@ func listAction(_ context.Context, cmd *cli.Command) error {
5859
openOnly := cmd.Bool("open")
5960
readyOnly := cmd.Bool("ready")
6061

62+
// Sort issues by timestamps (only what we'll display)
63+
sortByCreatedAt(readyIssues)
64+
if !readyOnly {
65+
sortByCreatedAt(blockedIssues)
66+
}
67+
if !openOnly && !readyOnly {
68+
sortByUpdatedAt(closedIssues)
69+
}
70+
6171
// Display READY section
6272
if _, err := fmt.Fprintln(w); err != nil {
6373
return err
@@ -136,3 +146,15 @@ func listAction(_ context.Context, cmd *cli.Command) error {
136146

137147
return nil
138148
}
149+
150+
func sortByCreatedAt(issues []*Issue) {
151+
sort.Slice(issues, func(i, j int) bool {
152+
return issues[i].CreatedAt.After(issues[j].CreatedAt)
153+
})
154+
}
155+
156+
func sortByUpdatedAt(issues []*Issue) {
157+
sort.Slice(issues, func(i, j int) bool {
158+
return issues[i].UpdatedAt.After(issues[j].UpdatedAt)
159+
})
160+
}

cmd_list_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"strings"
88
"testing"
9+
"time"
910
)
1011

1112
func TestListCommand(t *testing.T) {
@@ -645,3 +646,166 @@ func TestListCommandNewlines(t *testing.T) {
645646
t.Errorf("expected output to end with double newline (content newline + final blank), got: %q", output[len(output)-10:])
646647
}
647648
}
649+
650+
func TestListSortsReadyByCreatedAt(t *testing.T) {
651+
tmpDir := t.TempDir()
652+
filePath := filepath.Join(tmpDir, "mint-issues.yaml")
653+
t.Setenv("MINT_STORE_FILE", filePath)
654+
655+
store := NewStore()
656+
now := time.Now()
657+
658+
// Create 5 ready issues with different creation times, added in shuffled order
659+
store.Issues["issue3"] = &Issue{ID: "issue3", Title: "Third oldest", Status: "open", CreatedAt: now.Add(-3 * time.Hour)}
660+
store.Issues["issue1"] = &Issue{ID: "issue1", Title: "Oldest", Status: "open", CreatedAt: now.Add(-5 * time.Hour)}
661+
store.Issues["issue5"] = &Issue{ID: "issue5", Title: "Newest", Status: "open", CreatedAt: now}
662+
store.Issues["issue2"] = &Issue{ID: "issue2", Title: "Second oldest", Status: "open", CreatedAt: now.Add(-4 * time.Hour)}
663+
store.Issues["issue4"] = &Issue{ID: "issue4", Title: "Second newest", Status: "open", CreatedAt: now.Add(-1 * time.Hour)}
664+
665+
_ = store.Save(filePath)
666+
667+
cmd := newCommand()
668+
var buf bytes.Buffer
669+
cmd.Writer = &buf
670+
671+
err := cmd.Run(context.Background(), []string{"mint", "list"})
672+
if err != nil {
673+
t.Fatalf("list command failed: %v", err)
674+
}
675+
676+
output := stripANSI(buf.String())
677+
678+
// Find positions of each issue ID in output
679+
idx1 := strings.Index(output, "issue1")
680+
idx2 := strings.Index(output, "issue2")
681+
idx3 := strings.Index(output, "issue3")
682+
idx4 := strings.Index(output, "issue4")
683+
idx5 := strings.Index(output, "issue5")
684+
685+
// Verify all issues are present
686+
if idx1 == -1 || idx2 == -1 || idx3 == -1 || idx4 == -1 || idx5 == -1 {
687+
t.Fatalf("expected all issues in output, got: %s", output)
688+
}
689+
690+
// Verify order: newest to oldest (issue5, issue4, issue3, issue2, issue1)
691+
if idx5 >= idx4 || idx4 >= idx3 || idx3 >= idx2 || idx2 >= idx1 {
692+
t.Errorf("expected ready issues sorted by CreatedAt (newest first): issue5(%d), issue4(%d), issue3(%d), issue2(%d), issue1(%d)\noutput:\n%s",
693+
idx5, idx4, idx3, idx2, idx1, output)
694+
}
695+
}
696+
697+
func TestListSortsBlockedByCreatedAt(t *testing.T) {
698+
tmpDir := t.TempDir()
699+
filePath := filepath.Join(tmpDir, "mint-issues.yaml")
700+
t.Setenv("MINT_STORE_FILE", filePath)
701+
702+
store := NewStore()
703+
now := time.Now()
704+
705+
// Create a blocker issue
706+
store.Issues["blocker"] = &Issue{ID: "blocker", Title: "Blocker", Status: "open", CreatedAt: now}
707+
708+
// Create 5 blocked issues with different creation times, added in shuffled order
709+
store.Issues["blocked3"] = &Issue{ID: "blocked3", Title: "Third oldest blocked", Status: "open", CreatedAt: now.Add(-3 * time.Hour), DependsOn: []string{"blocker"}}
710+
store.Issues["blocked1"] = &Issue{ID: "blocked1", Title: "Oldest blocked", Status: "open", CreatedAt: now.Add(-5 * time.Hour), DependsOn: []string{"blocker"}}
711+
store.Issues["blocked5"] = &Issue{ID: "blocked5", Title: "Newest blocked", Status: "open", CreatedAt: now.Add(-30 * time.Minute), DependsOn: []string{"blocker"}}
712+
store.Issues["blocked2"] = &Issue{ID: "blocked2", Title: "Second oldest blocked", Status: "open", CreatedAt: now.Add(-4 * time.Hour), DependsOn: []string{"blocker"}}
713+
store.Issues["blocked4"] = &Issue{ID: "blocked4", Title: "Second newest blocked", Status: "open", CreatedAt: now.Add(-1 * time.Hour), DependsOn: []string{"blocker"}}
714+
715+
_ = store.Save(filePath)
716+
717+
cmd := newCommand()
718+
var buf bytes.Buffer
719+
cmd.Writer = &buf
720+
721+
err := cmd.Run(context.Background(), []string{"mint", "list"})
722+
if err != nil {
723+
t.Fatalf("list command failed: %v", err)
724+
}
725+
726+
output := stripANSI(buf.String())
727+
728+
// Find BLOCKED section
729+
blockedIdx := strings.Index(output, "BLOCKED")
730+
closedIdx := strings.Index(output, "CLOSED")
731+
if blockedIdx == -1 || closedIdx == -1 {
732+
t.Fatalf("expected BLOCKED and CLOSED sections in output")
733+
}
734+
735+
// Extract just the BLOCKED section
736+
blockedSection := output[blockedIdx:closedIdx]
737+
738+
// Find positions of each blocked issue in the BLOCKED section
739+
idx1 := strings.Index(blockedSection, "blocked1")
740+
idx2 := strings.Index(blockedSection, "blocked2")
741+
idx3 := strings.Index(blockedSection, "blocked3")
742+
idx4 := strings.Index(blockedSection, "blocked4")
743+
idx5 := strings.Index(blockedSection, "blocked5")
744+
745+
// Verify all blocked issues are present
746+
if idx1 == -1 || idx2 == -1 || idx3 == -1 || idx4 == -1 || idx5 == -1 {
747+
t.Fatalf("expected all blocked issues in BLOCKED section, got: %s", blockedSection)
748+
}
749+
750+
// Verify order: newest to oldest (blocked5, blocked4, blocked3, blocked2, blocked1)
751+
if idx5 >= idx4 || idx4 >= idx3 || idx3 >= idx2 || idx2 >= idx1 {
752+
t.Errorf("expected blocked issues sorted by CreatedAt (newest first): blocked5(%d), blocked4(%d), blocked3(%d), blocked2(%d), blocked1(%d)\nblocked section:\n%s",
753+
idx5, idx4, idx3, idx2, idx1, blockedSection)
754+
}
755+
}
756+
757+
func TestListSortsClosedByUpdatedAt(t *testing.T) {
758+
tmpDir := t.TempDir()
759+
filePath := filepath.Join(tmpDir, "mint-issues.yaml")
760+
t.Setenv("MINT_STORE_FILE", filePath)
761+
762+
store := NewStore()
763+
now := time.Now()
764+
765+
// Create 5 closed issues with different update times, added in shuffled order
766+
store.Issues["closed3"] = &Issue{ID: "closed3", Title: "Third oldest update", Status: "closed", CreatedAt: now.Add(-10 * time.Hour), UpdatedAt: now.Add(-3 * time.Hour)}
767+
store.Issues["closed1"] = &Issue{ID: "closed1", Title: "Oldest update", Status: "closed", CreatedAt: now.Add(-10 * time.Hour), UpdatedAt: now.Add(-5 * time.Hour)}
768+
store.Issues["closed5"] = &Issue{ID: "closed5", Title: "Newest update", Status: "closed", CreatedAt: now.Add(-10 * time.Hour), UpdatedAt: now}
769+
store.Issues["closed2"] = &Issue{ID: "closed2", Title: "Second oldest update", Status: "closed", CreatedAt: now.Add(-10 * time.Hour), UpdatedAt: now.Add(-4 * time.Hour)}
770+
store.Issues["closed4"] = &Issue{ID: "closed4", Title: "Second newest update", Status: "closed", CreatedAt: now.Add(-10 * time.Hour), UpdatedAt: now.Add(-1 * time.Hour)}
771+
772+
_ = store.Save(filePath)
773+
774+
cmd := newCommand()
775+
var buf bytes.Buffer
776+
cmd.Writer = &buf
777+
778+
err := cmd.Run(context.Background(), []string{"mint", "list"})
779+
if err != nil {
780+
t.Fatalf("list command failed: %v", err)
781+
}
782+
783+
output := stripANSI(buf.String())
784+
785+
// Find CLOSED section
786+
closedIdx := strings.Index(output, "CLOSED")
787+
if closedIdx == -1 {
788+
t.Fatalf("expected CLOSED section in output")
789+
}
790+
791+
// Extract just the CLOSED section (from CLOSED to end)
792+
closedSection := output[closedIdx:]
793+
794+
// Find positions of each closed issue in the CLOSED section
795+
idx1 := strings.Index(closedSection, "closed1")
796+
idx2 := strings.Index(closedSection, "closed2")
797+
idx3 := strings.Index(closedSection, "closed3")
798+
idx4 := strings.Index(closedSection, "closed4")
799+
idx5 := strings.Index(closedSection, "closed5")
800+
801+
// Verify all closed issues are present
802+
if idx1 == -1 || idx2 == -1 || idx3 == -1 || idx4 == -1 || idx5 == -1 {
803+
t.Fatalf("expected all closed issues in CLOSED section, got: %s", closedSection)
804+
}
805+
806+
// Verify order: newest to oldest by UpdatedAt (closed5, closed4, closed3, closed2, closed1)
807+
if idx5 >= idx4 || idx4 >= idx3 || idx3 >= idx2 || idx2 >= idx1 {
808+
t.Errorf("expected closed issues sorted by UpdatedAt (newest first): closed5(%d), closed4(%d), closed3(%d), closed2(%d), closed1(%d)\nclosed section:\n%s",
809+
idx5, idx4, idx3, idx2, idx1, closedSection)
810+
}
811+
}

mint-issues.yaml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ issues:
88
updated_at: 0001-01-01T00:00:00Z
99
comments:
1010
- "Closed with reason: Added"
11+
2vwz:
12+
id: 2vwz
13+
title: Update how we're sorting issues when listing them
14+
status: closed
15+
created_at: 2025-12-10T09:04:54.49869-08:00
16+
updated_at: 2025-12-10T09:22:04.677985-08:00
17+
comments:
18+
- Now that we have created at and updated timestamps, we should sort the list of issues differently. For ready and blocked issues, we should sort those by when they were created from newest to oldest. And the list of closed issues we should sort by when they were updated with the most recently updated at the top.
1119
3zpw65:
1220
id: 3zpw65
1321
title: Add a '--open' flag to 'list' to only show open issues
@@ -125,9 +133,9 @@ issues:
125133
f3ai:
126134
id: f3ai
127135
title: Store updated timestamp on issues
128-
status: open
136+
status: closed
129137
created_at: 0001-01-01T00:00:00Z
130-
updated_at: 0001-01-01T00:00:00Z
138+
updated_at: 2025-12-10T09:03:23.595953-08:00
131139
depends_on:
132140
- rmxy
133141
blocks:
@@ -353,9 +361,9 @@ issues:
353361
xu1v:
354362
id: xu1v
355363
title: Store created timestamp on issues
356-
status: open
364+
status: closed
357365
created_at: 0001-01-01T00:00:00Z
358-
updated_at: 0001-01-01T00:00:00Z
366+
updated_at: 2025-12-10T09:03:28.343507-08:00
359367
depends_on:
360368
- rmxy
361369
blocks:

0 commit comments

Comments
 (0)