GetBlockedIssues was showing external deps as blocking even when they were satisfied (had a closed issue with provides:capability label). Added filterBlockedByExternalDeps() which: - Collects all external refs from blocked issues - Checks each with CheckExternalDeps() in batch - Filters satisfied refs from BlockedBy lists - Updates BlockedByCount accordingly - Removes issues with no remaining blockers (unless status=blocked/deferred) This matches the behavior of GetReadyWork which already filters by external deps correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
597 lines
18 KiB
Go
597 lines
18 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// GetReadyWork returns issues with no open blockers
|
|
// By default, shows both 'open' and 'in_progress' issues so epics/tasks
|
|
// ready to close are visible (bd-165)
|
|
// Excludes pinned issues which are persistent anchors, not actionable work (bd-92u)
|
|
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
|
whereClauses := []string{
|
|
"i.pinned = 0", // Exclude pinned issues (bd-92u)
|
|
}
|
|
args := []interface{}{}
|
|
|
|
// Default to open OR in_progress if not specified (bd-165)
|
|
if filter.Status == "" {
|
|
whereClauses = append(whereClauses, "i.status IN ('open', 'in_progress')")
|
|
} else {
|
|
whereClauses = append(whereClauses, "i.status = ?")
|
|
args = append(args, filter.Status)
|
|
}
|
|
|
|
// Filter by issue type (gt-ktf3: MQ integration)
|
|
if filter.Type != "" {
|
|
whereClauses = append(whereClauses, "i.issue_type = ?")
|
|
args = append(args, filter.Type)
|
|
}
|
|
|
|
if filter.Priority != nil {
|
|
whereClauses = append(whereClauses, "i.priority = ?")
|
|
args = append(args, *filter.Priority)
|
|
}
|
|
|
|
// Unassigned takes precedence over Assignee filter
|
|
if filter.Unassigned {
|
|
whereClauses = append(whereClauses, "(i.assignee IS NULL OR i.assignee = '')")
|
|
} else if filter.Assignee != nil {
|
|
whereClauses = append(whereClauses, "i.assignee = ?")
|
|
args = append(args, *filter.Assignee)
|
|
}
|
|
|
|
// Label filtering (AND semantics)
|
|
if len(filter.Labels) > 0 {
|
|
for _, label := range filter.Labels {
|
|
whereClauses = append(whereClauses, `
|
|
EXISTS (
|
|
SELECT 1 FROM labels
|
|
WHERE issue_id = i.id AND label = ?
|
|
)
|
|
`)
|
|
args = append(args, label)
|
|
}
|
|
}
|
|
|
|
// Label filtering (OR semantics)
|
|
if len(filter.LabelsAny) > 0 {
|
|
placeholders := make([]string, len(filter.LabelsAny))
|
|
for i := range filter.LabelsAny {
|
|
placeholders[i] = "?"
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf(`
|
|
EXISTS (
|
|
SELECT 1 FROM labels
|
|
WHERE issue_id = i.id AND label IN (%s)
|
|
)
|
|
`, strings.Join(placeholders, ",")))
|
|
for _, label := range filter.LabelsAny {
|
|
args = append(args, label)
|
|
}
|
|
}
|
|
|
|
// Build WHERE clause properly
|
|
whereSQL := strings.Join(whereClauses, " AND ")
|
|
|
|
// Build LIMIT clause using parameter
|
|
limitSQL := ""
|
|
if filter.Limit > 0 {
|
|
limitSQL = " LIMIT ?"
|
|
args = append(args, filter.Limit)
|
|
}
|
|
|
|
// Default to hybrid sort for backwards compatibility
|
|
sortPolicy := filter.SortPolicy
|
|
if sortPolicy == "" {
|
|
sortPolicy = types.SortPolicyHybrid
|
|
}
|
|
orderBySQL := buildOrderByClause(sortPolicy)
|
|
|
|
// Use blocked_issues_cache for performance (bd-5qim)
|
|
// This optimization replaces the recursive CTE that computed blocked issues on every query.
|
|
// Performance improvement: 752ms → 29ms on 10K issues (25x speedup).
|
|
//
|
|
// The cache is automatically maintained by invalidateBlockedCache() which is called:
|
|
// - When adding/removing 'blocks' or 'parent-child' dependencies
|
|
// - When any issue status changes
|
|
// - When closing any issue
|
|
//
|
|
// Cache rebuild is fast (<50ms) and happens within the same transaction as the
|
|
// triggering change, ensuring consistency. See blocked_cache.go for full details.
|
|
// #nosec G201 - safe SQL with controlled formatting
|
|
query := fmt.Sprintf(`
|
|
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
|
|
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
|
|
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
|
|
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
|
i.sender, i.ephemeral, i.pinned, i.is_template
|
|
FROM issues i
|
|
WHERE %s
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM blocked_issues_cache WHERE issue_id = i.id
|
|
)
|
|
%s
|
|
%s
|
|
`, whereSQL, orderBySQL, limitSQL)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ready work: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
issues, err := s.scanIssues(ctx, rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter out issues with unsatisfied external dependencies (bd-zmmy)
|
|
// Only check if external_projects are configured
|
|
if len(config.GetExternalProjects()) > 0 && len(issues) > 0 {
|
|
issues, err = s.filterByExternalDeps(ctx, issues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check external dependencies: %w", err)
|
|
}
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// filterByExternalDeps removes issues that have unsatisfied external dependencies.
|
|
// External deps have format: external:<project>:<capability>
|
|
// They are satisfied when the target project has a closed issue with provides:<capability> label.
|
|
func (s *SQLiteStorage) filterByExternalDeps(ctx context.Context, issues []*types.Issue) ([]*types.Issue, error) {
|
|
if len(issues) == 0 {
|
|
return issues, nil
|
|
}
|
|
|
|
// Build list of issue IDs
|
|
issueIDs := make([]string, len(issues))
|
|
for i, issue := range issues {
|
|
issueIDs[i] = issue.ID
|
|
}
|
|
|
|
// Batch query: get all external deps for these issues
|
|
externalDeps, err := s.getExternalDepsForIssues(ctx, issueIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If no external deps, return all issues
|
|
if len(externalDeps) == 0 {
|
|
return issues, nil
|
|
}
|
|
|
|
// Check each external dep and build set of blocked issue IDs
|
|
blockedIssues := make(map[string]bool)
|
|
for issueID, deps := range externalDeps {
|
|
for _, dep := range deps {
|
|
status := CheckExternalDep(ctx, dep)
|
|
if !status.Satisfied {
|
|
blockedIssues[issueID] = true
|
|
break // One unsatisfied dep is enough to block
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out blocked issues
|
|
if len(blockedIssues) == 0 {
|
|
return issues, nil
|
|
}
|
|
|
|
result := make([]*types.Issue, 0, len(issues)-len(blockedIssues))
|
|
for _, issue := range issues {
|
|
if !blockedIssues[issue.ID] {
|
|
result = append(result, issue)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// getExternalDepsForIssues returns a map of issue ID -> list of external dep refs
|
|
func (s *SQLiteStorage) getExternalDepsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
|
|
if len(issueIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Build placeholders for IN clause
|
|
placeholders := make([]string, len(issueIDs))
|
|
args := make([]interface{}, len(issueIDs))
|
|
for i, id := range issueIDs {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
|
|
// #nosec G201 -- placeholders are "?" literals, not user input
|
|
query := fmt.Sprintf(`
|
|
SELECT issue_id, depends_on_id
|
|
FROM dependencies
|
|
WHERE issue_id IN (%s)
|
|
AND type = 'blocks'
|
|
AND depends_on_id LIKE 'external:%%'
|
|
`, strings.Join(placeholders, ","))
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query external dependencies: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result := make(map[string][]string)
|
|
for rows.Next() {
|
|
var issueID, depRef string
|
|
if err := rows.Scan(&issueID, &depRef); err != nil {
|
|
return nil, fmt.Errorf("failed to scan external dependency: %w", err)
|
|
}
|
|
result[issueID] = append(result[issueID], depRef)
|
|
}
|
|
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// GetStaleIssues returns issues that haven't been updated recently
|
|
func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
|
|
// Build query with optional status filter
|
|
query := `
|
|
SELECT
|
|
id, content_hash, title, description, design, acceptance_criteria, notes,
|
|
status, priority, issue_type, assignee, estimated_minutes,
|
|
created_at, updated_at, closed_at, external_ref, source_repo,
|
|
compaction_level, compacted_at, compacted_at_commit, original_size, close_reason,
|
|
deleted_at, deleted_by, delete_reason, original_type,
|
|
sender, ephemeral, pinned, is_template
|
|
FROM issues
|
|
WHERE status != 'closed'
|
|
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
|
|
`
|
|
|
|
args := []interface{}{filter.Days}
|
|
|
|
// Add optional status filter
|
|
if filter.Status != "" {
|
|
query += " AND status = ?"
|
|
args = append(args, filter.Status)
|
|
}
|
|
|
|
query += " ORDER BY updated_at ASC"
|
|
|
|
// Add limit
|
|
if filter.Limit > 0 {
|
|
query += " LIMIT ?"
|
|
args = append(args, filter.Limit)
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query stale issues: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var issues []*types.Issue
|
|
for rows.Next() {
|
|
var issue types.Issue
|
|
var closedAt sql.NullTime
|
|
var estimatedMinutes sql.NullInt64
|
|
var assignee sql.NullString
|
|
var externalRef sql.NullString
|
|
var sourceRepo sql.NullString
|
|
var contentHash sql.NullString
|
|
var compactionLevel sql.NullInt64
|
|
var compactedAt sql.NullTime
|
|
var compactedAtCommit sql.NullString
|
|
var originalSize sql.NullInt64
|
|
var closeReason sql.NullString
|
|
var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually
|
|
var deletedBy sql.NullString
|
|
var deleteReason sql.NullString
|
|
var originalType sql.NullString
|
|
// Messaging fields (bd-kwro)
|
|
var sender sql.NullString
|
|
var ephemeral sql.NullInt64
|
|
// Pinned field (bd-7h5)
|
|
var pinned sql.NullInt64
|
|
// Template field (beads-1ra)
|
|
var isTemplate sql.NullInt64
|
|
|
|
err := rows.Scan(
|
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
|
|
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason,
|
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
|
&sender, &ephemeral, &pinned, &isTemplate,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
|
|
}
|
|
|
|
if contentHash.Valid {
|
|
issue.ContentHash = contentHash.String
|
|
}
|
|
if closedAt.Valid {
|
|
issue.ClosedAt = &closedAt.Time
|
|
}
|
|
if estimatedMinutes.Valid {
|
|
mins := int(estimatedMinutes.Int64)
|
|
issue.EstimatedMinutes = &mins
|
|
}
|
|
if assignee.Valid {
|
|
issue.Assignee = assignee.String
|
|
}
|
|
if externalRef.Valid {
|
|
issue.ExternalRef = &externalRef.String
|
|
}
|
|
if sourceRepo.Valid {
|
|
issue.SourceRepo = sourceRepo.String
|
|
}
|
|
if compactionLevel.Valid {
|
|
issue.CompactionLevel = int(compactionLevel.Int64)
|
|
}
|
|
if compactedAt.Valid {
|
|
issue.CompactedAt = &compactedAt.Time
|
|
}
|
|
if compactedAtCommit.Valid {
|
|
issue.CompactedAtCommit = &compactedAtCommit.String
|
|
}
|
|
if originalSize.Valid {
|
|
issue.OriginalSize = int(originalSize.Int64)
|
|
}
|
|
if closeReason.Valid {
|
|
issue.CloseReason = closeReason.String
|
|
}
|
|
issue.DeletedAt = parseNullableTimeString(deletedAt)
|
|
if deletedBy.Valid {
|
|
issue.DeletedBy = deletedBy.String
|
|
}
|
|
if deleteReason.Valid {
|
|
issue.DeleteReason = deleteReason.String
|
|
}
|
|
if originalType.Valid {
|
|
issue.OriginalType = originalType.String
|
|
}
|
|
// Messaging fields (bd-kwro)
|
|
if sender.Valid {
|
|
issue.Sender = sender.String
|
|
}
|
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
|
issue.Wisp = true
|
|
}
|
|
// Pinned field (bd-7h5)
|
|
if pinned.Valid && pinned.Int64 != 0 {
|
|
issue.Pinned = true
|
|
}
|
|
// Template field (beads-1ra)
|
|
if isTemplate.Valid && isTemplate.Int64 != 0 {
|
|
issue.IsTemplate = true
|
|
}
|
|
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
return issues, rows.Err()
|
|
}
|
|
|
|
// GetBlockedIssues returns issues that are blocked by dependencies or have status=blocked
|
|
// Note: Pinned issues are excluded from the output (beads-ei4)
|
|
// Note: Includes external: references in blocked_by list (bd-om4a)
|
|
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
|
// Use UNION to combine:
|
|
// 1. Issues with open/in_progress/blocked status that have dependency blockers
|
|
// 2. Issues with status=blocked (even if they have no dependency blockers)
|
|
// Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1)
|
|
// Exclude pinned issues (beads-ei4)
|
|
//
|
|
// For blocked_by_count and blocker_ids:
|
|
// - Count local blockers (open issues) + external refs (external:*)
|
|
// - External refs are always considered "open" until resolved (bd-om4a)
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT
|
|
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
|
|
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
|
|
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
|
|
COALESCE(COUNT(d.depends_on_id), 0) as blocked_by_count,
|
|
COALESCE(GROUP_CONCAT(d.depends_on_id, ','), '') as blocker_ids
|
|
FROM issues i
|
|
LEFT JOIN dependencies d ON i.id = d.issue_id
|
|
AND d.type = 'blocks'
|
|
AND (
|
|
-- Local blockers: must be open/in_progress/blocked/deferred
|
|
EXISTS (
|
|
SELECT 1 FROM issues blocker
|
|
WHERE blocker.id = d.depends_on_id
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
|
)
|
|
-- External refs: always included (resolution happens at query time)
|
|
OR d.depends_on_id LIKE 'external:%'
|
|
)
|
|
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
|
AND i.pinned = 0
|
|
AND (
|
|
i.status = 'blocked'
|
|
OR i.status = 'deferred'
|
|
-- Has local open blockers
|
|
OR EXISTS (
|
|
SELECT 1 FROM dependencies d2
|
|
JOIN issues blocker ON d2.depends_on_id = blocker.id
|
|
WHERE d2.issue_id = i.id
|
|
AND d2.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
|
)
|
|
-- Has external blockers (always considered blocking until resolved)
|
|
OR EXISTS (
|
|
SELECT 1 FROM dependencies d3
|
|
WHERE d3.issue_id = i.id
|
|
AND d3.type = 'blocks'
|
|
AND d3.depends_on_id LIKE 'external:%'
|
|
)
|
|
)
|
|
GROUP BY i.id
|
|
ORDER BY i.priority ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var blocked []*types.BlockedIssue
|
|
for rows.Next() {
|
|
var issue types.BlockedIssue
|
|
var closedAt sql.NullTime
|
|
var estimatedMinutes sql.NullInt64
|
|
var assignee sql.NullString
|
|
var externalRef sql.NullString
|
|
var sourceRepo sql.NullString
|
|
var blockerIDsStr string
|
|
|
|
err := rows.Scan(
|
|
&issue.ID, &issue.Title, &issue.Description, &issue.Design,
|
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
|
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &issue.BlockedByCount,
|
|
&blockerIDsStr,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan blocked issue: %w", err)
|
|
}
|
|
|
|
if closedAt.Valid {
|
|
issue.ClosedAt = &closedAt.Time
|
|
}
|
|
if estimatedMinutes.Valid {
|
|
mins := int(estimatedMinutes.Int64)
|
|
issue.EstimatedMinutes = &mins
|
|
}
|
|
if assignee.Valid {
|
|
issue.Assignee = assignee.String
|
|
}
|
|
if externalRef.Valid {
|
|
issue.ExternalRef = &externalRef.String
|
|
}
|
|
if sourceRepo.Valid {
|
|
issue.SourceRepo = sourceRepo.String
|
|
}
|
|
|
|
// Parse comma-separated blocker IDs
|
|
if blockerIDsStr != "" {
|
|
issue.BlockedBy = strings.Split(blockerIDsStr, ",")
|
|
} else {
|
|
issue.BlockedBy = []string{}
|
|
}
|
|
|
|
blocked = append(blocked, &issue)
|
|
}
|
|
|
|
// Filter out satisfied external dependencies from BlockedBy lists (bd-396j)
|
|
// Only check if external_projects are configured
|
|
if len(config.GetExternalProjects()) > 0 && len(blocked) > 0 {
|
|
blocked = filterBlockedByExternalDeps(ctx, blocked)
|
|
}
|
|
|
|
return blocked, nil
|
|
}
|
|
|
|
// filterBlockedByExternalDeps removes satisfied external deps from BlockedBy lists.
|
|
// Issues with no remaining blockers are removed unless they have status=blocked/deferred.
|
|
func filterBlockedByExternalDeps(ctx context.Context, blocked []*types.BlockedIssue) []*types.BlockedIssue {
|
|
if len(blocked) == 0 {
|
|
return blocked
|
|
}
|
|
|
|
// Collect all unique external refs across all blocked issues
|
|
externalRefs := make(map[string]bool)
|
|
for _, issue := range blocked {
|
|
for _, ref := range issue.BlockedBy {
|
|
if strings.HasPrefix(ref, "external:") {
|
|
externalRefs[ref] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no external refs, return as-is
|
|
if len(externalRefs) == 0 {
|
|
return blocked
|
|
}
|
|
|
|
// Check all external refs in batch
|
|
refList := make([]string, 0, len(externalRefs))
|
|
for ref := range externalRefs {
|
|
refList = append(refList, ref)
|
|
}
|
|
statuses := CheckExternalDeps(ctx, refList)
|
|
|
|
// Build set of satisfied refs
|
|
satisfiedRefs := make(map[string]bool)
|
|
for ref, status := range statuses {
|
|
if status.Satisfied {
|
|
satisfiedRefs[ref] = true
|
|
}
|
|
}
|
|
|
|
// If nothing is satisfied, return as-is
|
|
if len(satisfiedRefs) == 0 {
|
|
return blocked
|
|
}
|
|
|
|
// Filter each issue's BlockedBy list
|
|
result := make([]*types.BlockedIssue, 0, len(blocked))
|
|
for _, issue := range blocked {
|
|
// Filter out satisfied external deps
|
|
var filteredBlockers []string
|
|
for _, ref := range issue.BlockedBy {
|
|
if !satisfiedRefs[ref] {
|
|
filteredBlockers = append(filteredBlockers, ref)
|
|
}
|
|
}
|
|
|
|
// Update issue with filtered blockers
|
|
issue.BlockedBy = filteredBlockers
|
|
issue.BlockedByCount = len(filteredBlockers)
|
|
|
|
// Keep issue if it has remaining blockers OR has blocked/deferred status
|
|
// (status=blocked/deferred issues always show even with no dep blockers)
|
|
if len(filteredBlockers) > 0 || issue.Status == "blocked" || issue.Status == "deferred" {
|
|
result = append(result, issue)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// buildOrderByClause generates the ORDER BY clause based on sort policy
|
|
func buildOrderByClause(policy types.SortPolicy) string {
|
|
switch policy {
|
|
case types.SortPolicyPriority:
|
|
return `ORDER BY i.priority ASC, i.created_at ASC`
|
|
|
|
case types.SortPolicyOldest:
|
|
return `ORDER BY i.created_at ASC`
|
|
|
|
case types.SortPolicyHybrid:
|
|
fallthrough
|
|
default:
|
|
return `ORDER BY
|
|
CASE
|
|
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0
|
|
ELSE 1
|
|
END ASC,
|
|
CASE
|
|
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority
|
|
ELSE NULL
|
|
END ASC,
|
|
CASE
|
|
WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at
|
|
ELSE NULL
|
|
END ASC,
|
|
i.created_at ASC`
|
|
}
|
|
}
|