Files
multica/server/pkg/db/queries/issue.sql
LinYushen 23136da34f feat(search): implement full-text search for issues (#507)
* feat(search): implement full-text search for issues

Add pg_bigm-based full-text search across issue titles and descriptions,
with API endpoint, CLI subcommand, and web Cmd+K search dialog.

- Migration 032: pg_bigm extension + GIN indexes on title/description
- Server: GET /api/issues/search?q=... with pagination and total count
- CLI: `multica issue search <query>` with table/json output
- Web: Cmd+K command palette using cmdk, with debounced search

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): address review feedback on search implementation

1. Escape LIKE special characters (%, _, \) in handler to prevent
   matching anomalies from user input.
2. Wire AbortController signal into searchIssues fetch so in-flight
   requests are actually cancelled on new input.
3. Fix offset=0 falsy check — use !== undefined instead of truthiness.
4. Merge results + count into single query using COUNT(*) OVER()
   window function, eliminating the duplicate DB round-trip.
5. Exclude done/cancelled issues by default; add include_closed
   parameter to API, CLI (--include-closed), and web client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(search): default web search to include all statuses

Pass include_closed: true in the web Cmd+K search so results include
done and cancelled issues by default, matching the reviewer's request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(search): add comment search with snippet extraction

Extend search to cover issue comments in addition to title/description.
Results are deduplicated at the issue level, with match_source and
matched_snippet fields indicating where and what matched.

- Migration 033: pg_bigm GIN index on comment.content
- SQL: EXISTS subquery for comment matching, correlated subquery for
  snippet extraction, 3-tier ranking (title > description > comment)
- Server: SearchIssueResponse with match_source and matched_snippet
- Web: show comment icon + snippet below issue title when matched
- CLI: MATCH column shows source and truncated snippet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(search): redesign search dialog to match Linear's spacious style

- Widen dialog from sm (384px) to xl (576px) with top-20% positioning
- Larger search input with icon, generous padding, and ESC hint
- Use cmdk primitives directly for full style control
- Taller result list (400px / 50vh), spacious result items (py-2.5)
- Rounded-lg items with accent highlight on selection
- Cleaner border separator between input and results

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:21 +08:00

113 lines
3.5 KiB
SQL

-- name: ListIssues :many
SELECT * FROM issue
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetIssue :one
SELECT * FROM issue
WHERE id = $1;
-- name: GetIssueInWorkspace :one
SELECT * FROM issue
WHERE id = $1 AND workspace_id = $2;
-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, number
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING *;
-- name: GetIssueByNumber :one
SELECT * FROM issue
WHERE workspace_id = $1 AND number = $2;
-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE(sqlc.narg('title'), title),
description = COALESCE(sqlc.narg('description'), description),
status = COALESCE(sqlc.narg('status'), status),
priority = COALESCE(sqlc.narg('priority'), priority),
assignee_type = sqlc.narg('assignee_type'),
assignee_id = sqlc.narg('assignee_id'),
position = COALESCE(sqlc.narg('position'), position),
due_date = sqlc.narg('due_date'),
parent_issue_id = sqlc.narg('parent_issue_id'),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdateIssueStatus :one
UPDATE issue SET
status = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteIssue :exec
DELETE FROM issue WHERE id = $1;
-- name: ListOpenIssues :many
SELECT * FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
ORDER BY position ASC, created_at DESC;
-- name: CountIssues :one
SELECT count(*) FROM issue
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'));
-- name: ListChildIssues :many
SELECT * FROM issue
WHERE parent_issue_id = $1
ORDER BY position ASC, created_at DESC;
-- name: SearchIssues :many
SELECT i.*,
COUNT(*) OVER() AS total_count,
CASE
WHEN i.title LIKE '%' || @query || '%' THEN 'title'
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 'description'
ELSE 'comment'
END AS match_source,
CASE
WHEN i.title LIKE '%' || @query || '%' THEN ''
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN ''
ELSE COALESCE(
(SELECT c.content FROM comment c
WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%'
ORDER BY c.created_at DESC LIMIT 1),
''
)
END AS matched_comment_content
FROM issue i
WHERE i.workspace_id = @workspace_id
AND (
i.title LIKE '%' || @query || '%'
OR COALESCE(i.description, '') LIKE '%' || @query || '%'
OR EXISTS (
SELECT 1 FROM comment c
WHERE c.issue_id = i.id AND c.content LIKE '%' || @query || '%'
)
)
AND (@include_closed::boolean OR i.status NOT IN ('done', 'cancelled'))
ORDER BY
CASE
WHEN i.title LIKE '%' || @query || '%' THEN 0
WHEN COALESCE(i.description, '') LIKE '%' || @query || '%' THEN 1
ELSE 2
END,
i.updated_at DESC
LIMIT @search_limit OFFSET @search_offset;