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:
committed by
GitHub
parent
e4042e3e1a
commit
d371baf2ca
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
67
internal/storage/sqlite/migrations/035_due_defer_columns.go
Normal file
67
internal/storage/sqlite/migrations/035_due_defer_columns.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
|
||||
227
internal/timeparsing/duration_test.go
Normal file
227
internal/timeparsing/duration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
287
internal/timeparsing/nlp_test.go
Normal file
287
internal/timeparsing/nlp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
180
internal/timeparsing/parser.go
Normal file
180
internal/timeparsing/parser.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user