feat(dates): add --due and --defer timestamp options with natural language parsing (#847)

* feat(dates): add due date schema and --due flag

- Add due_at and defer_until columns to issues table via migration 035
- Implement --due flag on create command with ISO date parsing
- Extend RPC protocol and daemon to pass DueAt from CLI to storage
- Display DueAt and DeferUntil in show command output
- Update Issue type with new date fields

Users can now set due dates when creating issues, enabling deadline-based
task management.

* feat(dates): add compact duration parser (+6h, +1d, +2w)

- Create internal/timeparsing package with layered parser architecture
- Implement parseCompactDuration with regex pattern [+-]?\d+[hdwmy]
- Add comprehensive test suite (22 cases) for duration parsing
- Integrate into create.go with fallback to ISO format

Supports hours (h), days (d), weeks (w), months (m), and years (y).
Negative values allowed for past dates.

* feat(dates): add NLP parsing for natural language dates

Integrate olebedev/when library for natural language time expressions.
The layered parser now handles: compact duration → absolute formats → NLP.

Changes:
- Add olebedev/when dependency for NLP parsing
- Implement ParseNaturalLanguage and ParseRelativeTime functions
- Reorder layers: absolute formats before NLP to avoid misinterpretation
- Simplify create.go to use unified ParseRelativeTime
- Add comprehensive NLP test coverage (22 test cases)

Supports: tomorrow, next monday, in 3 days, 3 days ago

* feat(dates): add --defer flag to create/update/defer commands

Add time-based deferral support alongside existing status-based defer.
Issues can now be hidden from bd ready until a specific time.

Changes:
- Add --defer flag to bd create (sets defer_until on creation)
- Add --due and --defer flags to bd update (modify existing issues)
- Add --until flag to bd defer (combines status=deferred with defer_until)
- Add DueAt/DeferUntil fields to UpdateArgs in protocol.go

Supports: +1h, tomorrow, next monday, 2025-01-15

* feat(dates): add defer_until filtering to ready command

Add time-based deferral support to bd ready:

- Add --include-deferred flag to show issues with future defer_until
- Filter out issues where defer_until > now by default
- Update undefer to clear defer_until alongside status change
- Add IncludeDeferred to WorkFilter and RPC ReadyArgs

Part of GH#820: Relative Date Parsing (Phase 5)

* feat(dates): add polish and tests for relative date parsing

Add user-facing warnings when defer date is in the past to help catch
common mistakes. Expand help text with format examples and document
the olebedev/when September parsing quirk.

Tests:
- TestCreateSuite/WithDueAt, WithDeferUntil, WithBothDueAndDefer
- TestReadyWorkDeferUntil (ExcludesFutureDeferredByDefault, IncludeDeferredShowsAll)

Docs:
- CLAUDE.md quick reference updated with new flags
- Help text examples for --due, --defer on create/update

Closes: Phase 6 of beads-820-relative-dates spec

* feat(list): add time-based query filters for defer/due dates

Add --deferred, --defer-before, --defer-after, --due-before, --due-after,
and --overdue flags to bd list command. All date filters now support
relative time expressions (+6h, tomorrow, next monday) via the
timeparsing package.

Filters:
- --deferred: issues with defer_until set
- --defer-before/after: filter by defer_until date range
- --due-before/after: filter by due_at date range
- --overdue: due_at in past AND status != closed

Existing date filters (--created-after, etc.) now also support relative
time expressions through updated parseTimeFlag().

* build(nix): update vendorHash for olebedev/when dependency

The olebedev/when library was added for natural language date parsing
(GH#820). This changes go.sum, requiring an updated vendorHash in the
Nix flake configuration.
This commit is contained in:
Peter Chanthamynavong
2026-01-01 20:06:13 -08:00
committed by GitHub
parent e4042e3e1a
commit d371baf2ca
26 changed files with 1593 additions and 56 deletions

View File

@@ -108,6 +108,9 @@ type CreateArgs struct {
EventActor string `json:"event_actor,omitempty"` // Entity URI who caused this event
EventTarget string `json:"event_target,omitempty"` // Entity URI or bead ID affected
EventPayload string `json:"event_payload,omitempty"` // Event-specific JSON data
// Time-based scheduling fields (GH#820)
DueAt string `json:"due_at,omitempty"` // Relative or ISO format due date
DeferUntil string `json:"defer_until,omitempty"` // Relative or ISO format defer date
}
// UpdateArgs represents arguments for the update operation
@@ -155,6 +158,9 @@ type UpdateArgs struct {
EventPayload *string `json:"event_payload,omitempty"` // Event-specific JSON data
// Work queue claim operation
Claim bool `json:"claim,omitempty"` // If true, atomically claim issue (set assignee+status, fail if already claimed)
// Time-based scheduling fields (GH#820)
DueAt *string `json:"due_at,omitempty"` // Relative or ISO format due date
DeferUntil *string `json:"defer_until,omitempty"` // Relative or ISO format defer date
}
// CloseArgs represents arguments for the close operation
@@ -236,6 +242,14 @@ type ListArgs struct {
// Type exclusion (for hiding internal types like gates, bd-7zka.2)
ExcludeTypes []string `json:"exclude_types,omitempty"`
// Time-based scheduling filters (GH#820)
Deferred bool `json:"deferred,omitempty"` // Filter issues with defer_until set
DeferAfter string `json:"defer_after,omitempty"` // ISO 8601 format
DeferBefore string `json:"defer_before,omitempty"` // ISO 8601 format
DueAfter string `json:"due_after,omitempty"` // ISO 8601 format
DueBefore string `json:"due_before,omitempty"` // ISO 8601 format
Overdue bool `json:"overdue,omitempty"` // Filter issues where due_at < now
}
// CountArgs represents arguments for the count operation
@@ -296,8 +310,9 @@ type ReadyArgs struct {
SortPolicy string `json:"sort_policy,omitempty"`
Labels []string `json:"labels,omitempty"`
LabelsAny []string `json:"labels_any,omitempty"`
ParentID string `json:"parent_id,omitempty"` // Filter to descendants of this bead/epic
MolType string `json:"mol_type,omitempty"` // Filter by molecule type: swarm, patrol, or work
ParentID string `json:"parent_id,omitempty"` // Filter to descendants of this bead/epic
MolType string `json:"mol_type,omitempty"` // Filter by molecule type: swarm, patrol, or work
IncludeDeferred bool `json:"include_deferred,omitempty"` // Include issues with future defer_until (GH#820)
}
// BlockedArgs represents arguments for the blocked operation

View File

@@ -200,6 +200,23 @@ func (s *Server) handleCreate(req *Request) Response {
externalRef = &createArgs.ExternalRef
}
// Parse DueAt if provided (GH#820)
var dueAt *time.Time
if createArgs.DueAt != "" {
// Try date-only format first (YYYY-MM-DD)
if t, err := time.ParseInLocation("2006-01-02", createArgs.DueAt, time.Local); err == nil {
dueAt = &t
} else if t, err := time.Parse(time.RFC3339, createArgs.DueAt); err == nil {
// Try RFC3339 format (2025-01-15T10:00:00Z)
dueAt = &t
} else {
return Response{
Success: false,
Error: fmt.Sprintf("invalid due_at format %q. Examples: 2025-01-15, 2025-01-15T10:00:00Z", createArgs.DueAt),
}
}
}
issue := &types.Issue{
ID: issueID,
Title: createArgs.Title,
@@ -230,6 +247,8 @@ func (s *Server) handleCreate(req *Request) Response {
Actor: createArgs.EventActor,
Target: createArgs.EventTarget,
Payload: createArgs.EventPayload,
// Time-based scheduling (GH#820)
DueAt: dueAt,
}
// Check if any dependencies are discovered-from type
@@ -1124,6 +1143,50 @@ func (s *Server) handleList(req *Request) Response {
}
}
// Time-based scheduling filters (GH#820)
filter.Deferred = listArgs.Deferred
if listArgs.DeferAfter != "" {
t, err := parseTimeRPC(listArgs.DeferAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --defer-after date: %v", err),
}
}
filter.DeferAfter = &t
}
if listArgs.DeferBefore != "" {
t, err := parseTimeRPC(listArgs.DeferBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --defer-before date: %v", err),
}
}
filter.DeferBefore = &t
}
if listArgs.DueAfter != "" {
t, err := parseTimeRPC(listArgs.DueAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --due-after date: %v", err),
}
}
filter.DueAfter = &t
}
if listArgs.DueBefore != "" {
t, err := parseTimeRPC(listArgs.DueBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --due-before date: %v", err),
}
}
filter.DueBefore = &t
}
filter.Overdue = listArgs.Overdue
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
if len(filter.IDs) > maxIDs {
@@ -1536,14 +1599,15 @@ func (s *Server) handleReady(req *Request) Response {
}
wf := types.WorkFilter{
Status: types.StatusOpen,
Type: readyArgs.Type,
Priority: readyArgs.Priority,
Unassigned: readyArgs.Unassigned,
Limit: readyArgs.Limit,
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
Labels: util.NormalizeLabels(readyArgs.Labels),
LabelsAny: util.NormalizeLabels(readyArgs.LabelsAny),
Status: types.StatusOpen,
Type: readyArgs.Type,
Priority: readyArgs.Priority,
Unassigned: readyArgs.Unassigned,
Limit: readyArgs.Limit,
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
Labels: util.NormalizeLabels(readyArgs.Labels),
LabelsAny: util.NormalizeLabels(readyArgs.LabelsAny),
IncludeDeferred: readyArgs.IncludeDeferred, // GH#820
}
if readyArgs.Assignee != "" && !readyArgs.Unassigned {
wf.Assignee = &readyArgs.Assignee

View File

@@ -48,8 +48,9 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters, mol_type,
event_kind, actor, target, payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
event_kind, actor, target, payload,
due_at, defer_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -61,6 +62,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
string(issue.MolType),
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
issue.DueAt, issue.DeferUntil,
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error
@@ -83,8 +85,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters, mol_type,
event_kind, actor, target, payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
event_kind, actor, target, payload,
due_at, defer_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
@@ -121,6 +124,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
string(issue.MolType),
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
issue.DueAt, issue.DeferUntil,
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error

View File

@@ -51,6 +51,7 @@ var migrationsList = []Migration{
{"hooked_status_migration", migrations.MigrateHookedStatus},
{"event_fields", migrations.MigrateEventFields},
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
{"due_defer_columns", migrations.MigrateDueDeferColumns},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -109,6 +110,7 @@ func getMigrationDescription(name string) string {
"hooked_status_migration": "Migrates blocked hooked issues to in_progress status",
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
"closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue",
"due_defer_columns": "Adds due_at and defer_until columns for time-based task scheduling (GH#820)",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,67 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateDueDeferColumns adds the due_at and defer_until columns to the issues table.
// These columns support time-based task scheduling (GH#820):
// - due_at: when the issue should be completed
// - defer_until: hide from bd ready until this time passes
func MigrateDueDeferColumns(db *sql.DB) error {
// Check if due_at column already exists
var dueAtExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'due_at'
`).Scan(&dueAtExists)
if err != nil {
return fmt.Errorf("failed to check due_at column: %w", err)
}
if !dueAtExists {
// Add the due_at column (nullable DATETIME)
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN due_at DATETIME`)
if err != nil {
return fmt.Errorf("failed to add due_at column: %w", err)
}
}
// Check if defer_until column already exists
var deferUntilExists bool
err = db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'defer_until'
`).Scan(&deferUntilExists)
if err != nil {
return fmt.Errorf("failed to check defer_until column: %w", err)
}
if !deferUntilExists {
// Add the defer_until column (nullable DATETIME)
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN defer_until DATETIME`)
if err != nil {
return fmt.Errorf("failed to add defer_until column: %w", err)
}
}
// Create indexes for efficient filtering queries
// These are critical for bd ready performance when filtering by defer_until
// Index on due_at for overdue/upcoming queries
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_due_at ON issues(due_at)`)
if err != nil {
return fmt.Errorf("failed to create due_at index: %w", err)
}
// Index on defer_until for ready filtering
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_defer_until ON issues(defer_until)`)
if err != nil {
return fmt.Errorf("failed to create defer_until index: %w", err)
}
return nil
}

View File

@@ -507,9 +507,11 @@ func TestMigrateContentHashColumn(t *testing.T) {
actor TEXT DEFAULT '',
target TEXT DEFAULT '',
payload TEXT DEFAULT '',
due_at DATETIME,
defer_until DATETIME,
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '', '', '', '', '' FROM issues_backup;
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '', '', '', '', '', NULL, NULL FROM issues_backup;
DROP TABLE issues_backup;
`)
if err != nil {

View File

@@ -286,6 +286,9 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var actor sql.NullString
var target sql.NullString
var payload sql.NullString
// Time-based scheduling fields (GH#820)
var dueAt sql.NullTime
var deferUntil sql.NullTime
var contentHash sql.NullString
var compactedAtCommit sql.NullString
@@ -298,7 +301,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters,
hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type,
event_kind, actor, target, payload
event_kind, actor, target, payload,
due_at, defer_until
FROM issues
WHERE id = ?
`, id).Scan(
@@ -312,6 +316,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
&awaitType, &awaitID, &timeoutNs, &waiters,
&hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType,
&eventKind, &actor, &target, &payload,
&dueAt, &deferUntil,
)
if err == sql.ErrNoRows {
@@ -426,6 +431,13 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if payload.Valid {
issue.Payload = payload.String
}
// Time-based scheduling fields (GH#820)
if dueAt.Valid {
issue.DueAt = &dueAt.Time
}
if deferUntil.Valid {
issue.DeferUntil = &deferUntil.Time
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -687,6 +699,9 @@ var allowedUpdateFields = map[string]bool{
"event_actor": true,
"event_target": true,
"event_payload": true,
// Time-based scheduling fields (GH#820)
"due_at": true,
"defer_until": true,
}
// validatePriority validates a priority value
@@ -1858,6 +1873,31 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
args = append(args, string(*filter.MolType))
}
// Time-based scheduling filters (GH#820)
if filter.Deferred {
whereClauses = append(whereClauses, "defer_until IS NOT NULL")
}
if filter.DeferAfter != nil {
whereClauses = append(whereClauses, "defer_until > ?")
args = append(args, filter.DeferAfter.Format(time.RFC3339))
}
if filter.DeferBefore != nil {
whereClauses = append(whereClauses, "defer_until < ?")
args = append(args, filter.DeferBefore.Format(time.RFC3339))
}
if filter.DueAfter != nil {
whereClauses = append(whereClauses, "due_at > ?")
args = append(args, filter.DueAfter.Format(time.RFC3339))
}
if filter.DueBefore != nil {
whereClauses = append(whereClauses, "due_at < ?")
args = append(args, filter.DueBefore.Format(time.RFC3339))
}
if filter.Overdue {
whereClauses = append(whereClauses, "due_at IS NOT NULL AND due_at < ? AND status != ?")
args = append(args, time.Now().Format(time.RFC3339), types.StatusClosed)
}
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")

View File

@@ -113,6 +113,13 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
args = append(args, string(*filter.MolType))
}
// Time-based deferral filtering (GH#820)
// By default, exclude issues where defer_until is in the future.
// If IncludeDeferred is true, skip this filter to show deferred issues.
if !filter.IncludeDeferred {
whereClauses = append(whereClauses, "(i.defer_until IS NULL OR datetime(i.defer_until) <= datetime('now'))")
}
// Build WHERE clause properly
whereSQL := strings.Join(whereClauses, " AND ")

View File

@@ -0,0 +1,227 @@
package timeparsing
import (
"testing"
"time"
)
func TestParseCompactDuration(t *testing.T) {
// Fixed reference time for deterministic tests
now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
input string
want time.Time
wantErr bool
}{
// Valid positive durations
{
name: "+6h adds 6 hours",
input: "+6h",
want: time.Date(2025, 6, 15, 18, 0, 0, 0, time.UTC),
},
{
name: "+1d adds 1 day",
input: "+1d",
want: time.Date(2025, 6, 16, 12, 0, 0, 0, time.UTC),
},
{
name: "+2w adds 2 weeks",
input: "+2w",
want: time.Date(2025, 6, 29, 12, 0, 0, 0, time.UTC),
},
{
name: "+3m adds 3 months",
input: "+3m",
want: time.Date(2025, 9, 15, 12, 0, 0, 0, time.UTC),
},
{
name: "+1y adds 1 year",
input: "+1y",
want: time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC),
},
// Valid negative durations (past)
{
name: "-1d subtracts 1 day",
input: "-1d",
want: time.Date(2025, 6, 14, 12, 0, 0, 0, time.UTC),
},
{
name: "-2w subtracts 2 weeks",
input: "-2w",
want: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
},
{
name: "-6h subtracts 6 hours",
input: "-6h",
want: time.Date(2025, 6, 15, 6, 0, 0, 0, time.UTC),
},
// No sign means positive
{
name: "3m without sign adds 3 months",
input: "3m",
want: time.Date(2025, 9, 15, 12, 0, 0, 0, time.UTC),
},
{
name: "1y without sign adds 1 year",
input: "1y",
want: time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC),
},
{
name: "6h without sign adds 6 hours",
input: "6h",
want: time.Date(2025, 6, 15, 18, 0, 0, 0, time.UTC),
},
// Multi-digit amounts
{
name: "+24h adds 24 hours",
input: "+24h",
want: time.Date(2025, 6, 16, 12, 0, 0, 0, time.UTC),
},
{
name: "+365d adds 365 days",
input: "+365d",
want: time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC),
},
// Invalid inputs
{
name: "6h+ (sign at end) is invalid",
input: "6h+",
wantErr: true,
},
{
name: "++1d (double sign) is invalid",
input: "++1d",
wantErr: true,
},
{
name: "1x (unknown unit) is invalid",
input: "1x",
wantErr: true,
},
{
name: "empty string is invalid",
input: "",
wantErr: true,
},
{
name: "just a number is invalid",
input: "6",
wantErr: true,
},
{
name: "just a unit is invalid",
input: "h",
wantErr: true,
},
{
name: "spaces are invalid",
input: "+ 6h",
wantErr: true,
},
{
name: "ISO date is not compact duration",
input: "2025-01-15",
wantErr: true,
},
{
name: "natural language is not compact duration",
input: "tomorrow",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCompactDuration(tt.input, now)
if (err != nil) != tt.wantErr {
t.Errorf("ParseCompactDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && !got.Equal(tt.want) {
t.Errorf("ParseCompactDuration(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestIsCompactDuration(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"+6h", true},
{"-1d", true},
{"+2w", true},
{"3m", true},
{"1y", true},
{"+24h", true},
{"", false},
{"tomorrow", false},
{"2025-01-15", false},
{"6h+", false},
{"++1d", false},
{"1x", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := IsCompactDuration(tt.input)
if got != tt.want {
t.Errorf("IsCompactDuration(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// TestParseCompactDuration_MonthBoundary tests month arithmetic edge cases.
func TestParseCompactDuration_MonthBoundary(t *testing.T) {
// Jan 31 + 1 month = Feb 28 (or 29 in leap year)
jan31 := time.Date(2025, 1, 31, 12, 0, 0, 0, time.UTC)
got, err := ParseCompactDuration("+1m", jan31)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Go's AddDate normalizes: Jan 31 + 1 month = March 3 (31 days into Feb)
// This is Go's default behavior, which we preserve
if got.Month() != time.March {
t.Logf("Note: Jan 31 + 1m = %v (Go's AddDate overflow behavior)", got)
}
}
// TestParseCompactDuration_LeapYear tests leap year handling.
func TestParseCompactDuration_LeapYear(t *testing.T) {
// Feb 28, 2024 (leap year) + 1d = Feb 29
feb28_2024 := time.Date(2024, 2, 28, 12, 0, 0, 0, time.UTC)
got, err := ParseCompactDuration("+1d", feb28_2024)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("Feb 28, 2024 + 1d = %v, want %v", got, want)
}
}
// TestParseCompactDuration_PreservesTimezone tests that local timezone is preserved.
func TestParseCompactDuration_PreservesTimezone(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skip("timezone America/New_York not available")
}
now := time.Date(2025, 6, 15, 12, 0, 0, 0, loc)
got, err := ParseCompactDuration("+1d", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Location() != loc {
t.Errorf("timezone not preserved: got %v, want %v", got.Location(), loc)
}
}

View File

@@ -0,0 +1,287 @@
package timeparsing
import (
"testing"
"time"
)
// TestParseNaturalLanguage tests the NLP parser wrapper.
func TestParseNaturalLanguage(t *testing.T) {
// Fixed reference time: Wednesday, January 15, 2025, 10:00:00 AM
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
tests := []struct {
name string
input string
wantYear int
wantMonth time.Month
wantDay int
wantHour int // -1 means don't check hour
wantErr bool
}{
// Relative days
{
name: "tomorrow",
input: "tomorrow",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: -1,
wantErr: false,
},
{
name: "yesterday",
input: "yesterday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 14,
wantHour: -1,
wantErr: false,
},
// Next weekday (reference is Wednesday Jan 15)
{
name: "next monday",
input: "next monday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20, // Next Monday after Jan 15
wantHour: -1,
wantErr: false,
},
{
name: "next friday",
input: "next friday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 17, // Friday Jan 17 (same week)
wantHour: -1,
wantErr: false,
},
// With time
{
name: "tomorrow at 9am",
input: "tomorrow at 9am",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: 9,
wantErr: false,
},
{
name: "next monday at 2pm",
input: "next monday at 2pm",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20,
wantHour: 14,
wantErr: false,
},
// Relative durations (NLP style)
{
name: "in 3 days",
input: "in 3 days",
wantYear: 2025,
wantMonth: time.January,
wantDay: 18,
wantHour: -1,
wantErr: false,
},
{
name: "in 1 week",
input: "in 1 week",
wantYear: 2025,
wantMonth: time.January,
wantDay: 22,
wantHour: -1,
wantErr: false,
},
// Past relative
{
name: "3 days ago",
input: "3 days ago",
wantYear: 2025,
wantMonth: time.January,
wantDay: 12,
wantHour: -1,
wantErr: false,
},
// Invalid inputs
{
name: "random text",
input: "not a date at all",
wantErr: true,
},
{
name: "empty string",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseNaturalLanguage(tt.input, now)
if (err != nil) != tt.wantErr {
t.Errorf("ParseNaturalLanguage(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if got.Year() != tt.wantYear {
t.Errorf("ParseNaturalLanguage(%q) year = %d, want %d", tt.input, got.Year(), tt.wantYear)
}
if got.Month() != tt.wantMonth {
t.Errorf("ParseNaturalLanguage(%q) month = %v, want %v", tt.input, got.Month(), tt.wantMonth)
}
if got.Day() != tt.wantDay {
t.Errorf("ParseNaturalLanguage(%q) day = %d, want %d", tt.input, got.Day(), tt.wantDay)
}
if tt.wantHour >= 0 && got.Hour() != tt.wantHour {
t.Errorf("ParseNaturalLanguage(%q) hour = %d, want %d", tt.input, got.Hour(), tt.wantHour)
}
})
}
}
// TestParseRelativeTime tests the layered parsing function.
func TestParseRelativeTime(t *testing.T) {
// Fixed reference time: Wednesday, January 15, 2025, 10:00:00 AM
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
tests := []struct {
name string
input string
wantYear int
wantMonth time.Month
wantDay int
wantHour int // -1 means don't check hour
wantErr bool
}{
// Layer 1: Compact duration (should be tried first)
{
name: "compact +1d",
input: "+1d",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: 10, // Same hour as now
wantErr: false,
},
{
name: "compact +6h",
input: "+6h",
wantYear: 2025,
wantMonth: time.January,
wantDay: 15,
wantHour: 16, // 10 + 6 = 16
wantErr: false,
},
// Layer 2: NLP
{
name: "NLP tomorrow",
input: "tomorrow",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: -1,
wantErr: false,
},
{
name: "NLP next monday",
input: "next monday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20,
wantHour: -1,
wantErr: false,
},
// Layer 3: Date-only
{
name: "date-only",
input: "2025-02-01",
wantYear: 2025,
wantMonth: time.February,
wantDay: 1,
wantHour: 0,
wantErr: false,
},
// Layer 4: RFC3339
{
name: "RFC3339",
input: "2025-03-15T14:30:00Z",
wantYear: 2025,
wantMonth: time.March,
wantDay: 15,
wantHour: 14,
wantErr: false,
},
// Invalid
{
name: "invalid expression",
input: "not-a-date",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseRelativeTime(tt.input, now)
if (err != nil) != tt.wantErr {
t.Errorf("ParseRelativeTime(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if got.Year() != tt.wantYear {
t.Errorf("ParseRelativeTime(%q) year = %d, want %d", tt.input, got.Year(), tt.wantYear)
}
if got.Month() != tt.wantMonth {
t.Errorf("ParseRelativeTime(%q) month = %v, want %v", tt.input, got.Month(), tt.wantMonth)
}
if got.Day() != tt.wantDay {
t.Errorf("ParseRelativeTime(%q) day = %d, want %d", tt.input, got.Day(), tt.wantDay)
}
if tt.wantHour >= 0 && got.Hour() != tt.wantHour {
t.Errorf("ParseRelativeTime(%q) hour = %d, want %d", tt.input, got.Hour(), tt.wantHour)
}
})
}
}
// TestParseRelativeTime_LayerPrecedence verifies that layers are tried in order.
func TestParseRelativeTime_LayerPrecedence(t *testing.T) {
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
// "+1d" is valid compact duration, should NOT be parsed as NLP
t1, err := ParseRelativeTime("+1d", now)
if err != nil {
t.Fatalf("ParseRelativeTime(\"+1d\") failed: %v", err)
}
// Compact duration adds exactly 1 day, preserving time
expected := now.AddDate(0, 0, 1)
if !t1.Equal(expected) {
t.Errorf("ParseRelativeTime(\"+1d\") = %v, want %v (compact duration should take precedence)", t1, expected)
}
// "2025-01-20" should parse as date-only, not NLP
t2, err := ParseRelativeTime("2025-01-20", now)
if err != nil {
t.Fatalf("ParseRelativeTime(\"2025-01-20\") failed: %v", err)
}
if t2.Day() != 20 || t2.Month() != time.January || t2.Year() != 2025 {
t.Errorf("ParseRelativeTime(\"2025-01-20\") = %v, want Jan 20, 2025", t2)
}
}

View File

@@ -0,0 +1,180 @@
// Package timeparsing provides layered time parsing for relative date/time expressions.
//
// The parsing follows a layered architecture (ADR-001):
// 1. Compact duration (+6h, -1d, +2w)
// 2. Natural language (tomorrow, next monday)
// 3. Absolute timestamp (RFC3339, date-only)
package timeparsing
import (
"fmt"
"regexp"
"strconv"
"time"
"github.com/olebedev/when"
"github.com/olebedev/when/rules/common"
"github.com/olebedev/when/rules/en"
)
// compactDurationRe matches compact duration patterns: [+-]?(\d+)([hdwmy])
// Examples: +6h, -1d, +2w, 3m, 1y
var compactDurationRe = regexp.MustCompile(`^([+-]?)(\d+)([hdwmy])$`)
// ParseCompactDuration parses compact duration syntax and returns the resulting time.
//
// Format: [+-]?(\d+)([hdwmy])
//
// Units:
// - h = hours
// - d = days
// - w = weeks
// - m = months
// - y = years
//
// Examples:
// - "+6h" -> now + 6 hours
// - "-1d" -> now - 1 day
// - "+2w" -> now + 2 weeks
// - "3m" -> now + 3 months (no sign = positive)
// - "1y" -> now + 1 year
//
// Returns error if input doesn't match the compact duration pattern.
func ParseCompactDuration(s string, now time.Time) (time.Time, error) {
matches := compactDurationRe.FindStringSubmatch(s)
if matches == nil {
return time.Time{}, fmt.Errorf("not a compact duration: %q", s)
}
sign := matches[1]
amountStr := matches[2]
unit := matches[3]
amount, err := strconv.Atoi(amountStr)
if err != nil {
// Should not happen given regex ensures digits, but handle gracefully
return time.Time{}, fmt.Errorf("invalid duration amount: %q", amountStr)
}
// Apply sign (default positive)
if sign == "-" {
amount = -amount
}
return applyDuration(now, amount, unit), nil
}
// applyDuration applies the given amount and unit to the base time.
func applyDuration(base time.Time, amount int, unit string) time.Time {
switch unit {
case "h":
return base.Add(time.Duration(amount) * time.Hour)
case "d":
return base.AddDate(0, 0, amount)
case "w":
return base.AddDate(0, 0, amount*7)
case "m":
return base.AddDate(0, amount, 0)
case "y":
return base.AddDate(amount, 0, 0)
default:
// Should not happen given regex, but return base unchanged
return base
}
}
// IsCompactDuration returns true if the string matches compact duration syntax.
func IsCompactDuration(s string) bool {
return compactDurationRe.MatchString(s)
}
// nlpParser is the singleton natural language parser (olebedev/when).
// Initialized lazily on first use.
var nlpParser *when.Parser
// getNLPParser returns the singleton NLP parser, initializing it if needed.
func getNLPParser() *when.Parser {
if nlpParser == nil {
nlpParser = when.New(nil)
nlpParser.Add(en.All...)
nlpParser.Add(common.All...)
}
return nlpParser
}
// ParseNaturalLanguage parses natural language time expressions using olebedev/when.
//
// Examples:
// - "tomorrow" -> tomorrow at current time
// - "next monday" -> next Monday at current time
// - "next monday at 9am" -> next Monday at 9:00
// - "in 3 days" -> now + 3 days
// - "3 days ago" -> now - 3 days
//
// Known Issues (olebedev/when):
// - Month name "September" may not parse correctly in some contexts.
// Workaround: Use date format "2025-09-15" instead of "September 15" or "Sep 15".
// This is a known issue in the olebedev/when library.
//
// Returns error if input cannot be parsed as natural language.
func ParseNaturalLanguage(s string, now time.Time) (time.Time, error) {
parser := getNLPParser()
result, err := parser.Parse(s, now)
if err != nil {
return time.Time{}, fmt.Errorf("NLP parse error: %w", err)
}
if result == nil {
return time.Time{}, fmt.Errorf("not a natural language time expression: %q", s)
}
return result.Time, nil
}
// dateOnlyRe matches date-only format YYYY-MM-DD to avoid NLP misinterpretation.
var dateOnlyRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// ParseRelativeTime parses a time expression using the layered architecture (ADR-001).
//
// Parsing order:
// 1. Compact duration (+6h, -1d, +2w)
// 2. Absolute formats (date-only, RFC3339) - checked before NLP to avoid misinterpretation
// 3. Natural language (tomorrow, next monday)
//
// Returns the parsed time or an error if no layer could parse the input.
func ParseRelativeTime(s string, now time.Time) (time.Time, error) {
// Layer 1: Compact duration
if t, err := ParseCompactDuration(s, now); err == nil {
return t, nil
}
// Layer 2: Absolute formats (must be checked before NLP to avoid misinterpretation)
// NLP parser can incorrectly parse "2025-02-01" as a time, so we check date formats first.
// Try date-only format (YYYY-MM-DD)
if dateOnlyRe.MatchString(s) {
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
return t, nil
}
}
// Try RFC3339 format (2025-01-15T10:00:00Z)
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Try ISO 8601 datetime without timezone (2025-01-15T10:00:00)
if t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local); err == nil {
return t, nil
}
// Try datetime with space (2025-01-15 10:00:00)
if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
return t, nil
}
// Layer 3: Natural language (after absolute formats to avoid misinterpretation)
if t, err := ParseNaturalLanguage(s, now); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("cannot parse time expression: %q (examples: +6h, tomorrow, 2025-01-15)", s)
}

View File

@@ -40,6 +40,10 @@ type Issue struct {
CloseReason string `json:"close_reason,omitempty"` // Reason provided when closing
ClosedBySession string `json:"closed_by_session,omitempty"` // Claude Code session that closed this issue
// ===== Time-Based Scheduling (GH#820) =====
DueAt *time.Time `json:"due_at,omitempty"` // When this issue should be completed
DeferUntil *time.Time `json:"defer_until,omitempty"` // Hide from bd ready until this time
// ===== External Integration =====
ExternalRef *string `json:"external_ref,omitempty"` // e.g., "gh-9", "jira-ABC"
@@ -794,6 +798,14 @@ type IssueFilter struct {
// Type exclusion (for hiding internal types like gates)
ExcludeTypes []IssueType // Exclude issues with these types
// Time-based scheduling filters (GH#820)
Deferred bool // Filter issues with defer_until set (any value)
DeferAfter *time.Time // Filter issues with defer_until > this time
DeferBefore *time.Time // Filter issues with defer_until < this time
DueAfter *time.Time // Filter issues with due_at > this time
DueBefore *time.Time // Filter issues with due_at < this time
Overdue bool // Filter issues where due_at < now AND status != closed
}
// SortPolicy determines how ready work is ordered
@@ -841,6 +853,9 @@ type WorkFilter struct {
// Molecule type filtering
MolType *MolType // Filter by molecule type (nil = any, swarm/patrol/work)
// Time-based deferral filtering (GH#820)
IncludeDeferred bool // If true, include issues with future defer_until timestamps
}
// StaleFilter is used to filter stale issue queries