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
@@ -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 ")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user