feat(issues): add structured ticket search
This commit is contained in:
parent
efe131591f
commit
34c39b765e
11 changed files with 1033 additions and 27 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -299,6 +300,57 @@ func TestCommentCRUD(t *testing.T) {
|
|||
testHandler.DeleteIssue(w, req)
|
||||
}
|
||||
|
||||
func TestListIssuesAllIgnoresPagination(t *testing.T) {
|
||||
createdIDs := make([]string, 0, 3)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Searchable issue " + strconv.Itoa(i+1),
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
createdIDs = append(createdIDs, issue.ID)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
for _, issueID := range createdIDs {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(w, req)
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID+"&all=true&limit=1", nil)
|
||||
testHandler.ListIssues(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListIssues(all=true): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var listResp struct {
|
||||
Issues []IssueResponse `json:"issues"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
|
||||
t.Fatalf("ListIssues(all=true): decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(listResp.Issues) < 3 {
|
||||
t.Fatalf("ListIssues(all=true): expected at least 3 issues, got %d", len(listResp.Issues))
|
||||
}
|
||||
|
||||
if listResp.Total != len(listResp.Issues) {
|
||||
t.Fatalf("ListIssues(all=true): expected total %d, got %d", len(listResp.Issues), listResp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCRUD(t *testing.T) {
|
||||
// List agents
|
||||
w := httptest.NewRecorder()
|
||||
|
|
|
|||
|
|
@ -103,6 +103,31 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("all") == "true" {
|
||||
issues, err := h.Queries.ListAllIssues(ctx, db.ListAllIssuesParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Status: statusFilterFromQuery(r),
|
||||
Priority: priorityFilter,
|
||||
AssigneeID: assigneeFilter,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
||||
return
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, wsUUID)
|
||||
resp := make([]IssueResponse, len(issues))
|
||||
for i, issue := range issues {
|
||||
resp[i] = issueToResponse(issue, prefix)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"issues": resp,
|
||||
"total": len(resp),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
offset := 0
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
|
|
@ -116,10 +141,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
var statusFilter pgtype.Text
|
||||
if s := r.URL.Query().Get("status"); s != "" {
|
||||
statusFilter = pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
statusFilter := statusFilterFromQuery(r)
|
||||
|
||||
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
|
||||
WorkspaceID: wsUUID,
|
||||
|
|
@ -157,6 +179,14 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func statusFilterFromQuery(r *http.Request) pgtype.Text {
|
||||
var statusFilter pgtype.Text
|
||||
if s := r.URL.Query().Get("status"); s != "" {
|
||||
statusFilter = pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
return statusFilter
|
||||
}
|
||||
|
||||
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
|
|
|
|||
|
|
@ -216,6 +216,66 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
|
|||
return i, err
|
||||
}
|
||||
|
||||
const listAllIssues = `-- name: ListAllIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND ($2::text IS NULL OR status = $2)
|
||||
AND ($3::text IS NULL OR priority = $3)
|
||||
AND ($4::uuid IS NULL OR assignee_id = $4)
|
||||
ORDER BY position ASC, created_at DESC
|
||||
`
|
||||
|
||||
type ListAllIssuesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAllIssues(ctx context.Context, arg ListAllIssuesParams) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listAllIssues,
|
||||
arg.WorkspaceID,
|
||||
arg.Status,
|
||||
arg.Priority,
|
||||
arg.AssigneeID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Issue{}
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
|
||||
WHERE workspace_id = $1
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ WHERE workspace_id = $1
|
|||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: ListAllIssues :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;
|
||||
|
||||
-- name: GetIssue :one
|
||||
SELECT * FROM issue
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue