Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLI_AND_DAEMON.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,12 @@ multica issue list --full-id
multica issue list --limit 20 --output json
multica issue list --status todo --sort position # board order (the default)
multica issue list --sort created_at --direction desc # newest first
multica issue list --sort updated_at --direction desc # recently updated first
```

Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.

Results come back in board order (`position`, ascending) by default. Pass `--sort` to change the column (`position`, `title`, `created_at`, `start_date`, `due_date`, `priority`) and `--direction asc|desc` to flip the order. `position` is always ascending (it is the manual drag order), so `--direction` is rejected when `--sort` is `position` or omitted — use it only with `title`, `created_at`, `start_date`, `due_date`, or `priority`.
Results come back in board order (`position`, ascending) by default. Pass `--sort` to change the column (`position`, `title`, `created_at`, `updated_at`, `start_date`, `due_date`, `priority`) and `--direction asc|desc` to flip the order. `position` is always ascending (it is the manual drag order), so `--direction` is rejected when `--sort` is `position` or omitted — use it only with `title`, `created_at`, `updated_at`, `start_date`, `due_date`, or `priority`.

Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:

Expand Down
3 changes: 2 additions & 1 deletion packages/core/issues/stores/view-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type SwimlaneGrouping = "parent" | "project" | "assignee";
* static fields, so property sorts fall back to `position` server-side and
* re-sort in the surface data hook.
*/
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title" | `property:${string}`;
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "updated_at" | "title" | `property:${string}`;
export type SortDirection = "asc" | "desc";
export type IssueDateField = "created_at" | "updated_at";

Expand Down Expand Up @@ -68,6 +68,7 @@ export const SORT_OPTIONS: { value: StaticSortField; label: string }[] = [
{ value: "start_date", label: "Start date" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "updated_at", label: "Updated date" },
{ value: "title", label: "Title" },
];

Expand Down
4 changes: 2 additions & 2 deletions packages/core/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export interface ListIssuesParams {
date_field?: "created_at" | "updated_at";
date_start?: string;
date_end?: string;
sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`;
sort_by?: "position" | "priority" | "title" | "created_at" | "updated_at" | "start_date" | "due_date" | `property:${string}`;
sort_direction?: "asc" | "desc";
}

Expand Down Expand Up @@ -156,7 +156,7 @@ export interface ListGroupedIssuesParams {
date_field?: "created_at" | "updated_at";
date_start?: string;
date_end?: string;
sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date" | `property:${string}`;
sort_by?: "position" | "priority" | "title" | "created_at" | "updated_at" | "start_date" | "due_date" | `property:${string}`;
sort_direction?: "asc" | "desc";
}

Expand Down
6 changes: 5 additions & 1 deletion packages/views/issues/components/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ function BoardViewImpl({
},
[setIssuePropertyMutation, t, unsetIssuePropertyMutation],
);
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
const sortFieldKey = sortBy === "created_at"
? "created"
: sortBy === "updated_at"
? "updated"
: sortBy;
const sortPropertyId = propertyIdFromViewKey(sortBy);
const sortLabel = sortBy !== "position"
? t(($) => $.board.ordered_by, {
Expand Down
3 changes: 2 additions & 1 deletion packages/views/issues/components/issues-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -948,12 +948,13 @@ export function IssueDisplayControls({
});
const hasActiveFilters = activeFilterCount > 0;

const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_title"> = {
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_updated" | "sort_title"> = {
position: "sort_manual",
priority: "sort_priority",
start_date: "sort_start_date",
due_date: "sort_due_date",
created_at: "sort_created",
updated_at: "sort_updated",
title: "sort_title",
};
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
Expand Down
1 change: 1 addition & 0 deletions packages/views/issues/components/issues-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "updated_at", label: "Updated date" },
{ value: "title", label: "Title" },
],
GROUPING_OPTIONS: [
Expand Down
6 changes: 5 additions & 1 deletion packages/views/issues/components/list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ function ListViewImpl({
const sortBy = useViewStore((s) => s.sortBy);
const { t } = useT("issues");

const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
const sortFieldKey = sortBy === "created_at"
? "created"
: sortBy === "updated_at"
? "updated"
: sortBy;
const sortLabel = sortBy !== "position"
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
: null;
Expand Down
23 changes: 23 additions & 0 deletions packages/views/issues/utils/sort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ function issueWith(id: string, value?: number | string, position = 0): Issue {
} as unknown as Issue;
}

function issueUpdatedAt(id: string, updatedAt: string): Issue {
return {
id,
position: 0,
updated_at: updatedAt,
properties: {},
} as unknown as Issue;
}

describe("sortIssues property sorts", () => {
it("sorts number values numerically, missing values last", () => {
const sorted = sortIssues(
Expand Down Expand Up @@ -49,3 +58,17 @@ describe("sortIssues property sorts", () => {
expect(sorted.map((i) => i.id)).toEqual(["a", "b"]);
});
});

describe("sortIssues updated date sort", () => {
it("sorts updated_at in both directions", () => {
const issues = [
issueUpdatedAt("older", "2026-07-15T10:00:00Z"),
issueUpdatedAt("newer", "2026-07-16T10:00:00Z"),
];

expect(sortIssues(issues, "updated_at", "asc").map((issue) => issue.id))
.toEqual(["older", "newer"]);
expect(sortIssues(issues, "updated_at", "desc").map((issue) => issue.id))
.toEqual(["newer", "older"]);
});
});
4 changes: 4 additions & 0 deletions packages/views/issues/utils/sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function sortIssues(
return (
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
case "updated_at":
return (
new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()
);
case "title":
return a.title.localeCompare(b.title);
case "position":
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/en/issues.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"sort_start_date": "Start date",
"sort_due_date": "Due date",
"sort_created": "Created date",
"sort_updated": "Updated date",
"sort_title": "Title",
"card_priority": "Priority",
"card_description": "Description",
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/ja/issues.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"sort_start_date": "開始日",
"sort_due_date": "期限",
"sort_created": "作成日",
"sort_updated": "更新日",
"sort_title": "タイトル",
"card_priority": "優先度",
"card_description": "説明",
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/ko/issues.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"sort_start_date": "시작일",
"sort_due_date": "마감일",
"sort_created": "생성일",
"sort_updated": "업데이트 날짜",
"sort_title": "제목",
"card_priority": "우선순위",
"card_description": "설명",
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/zh-Hans/issues.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"sort_start_date": "开始日期",
"sort_due_date": "截止日期",
"sort_created": "创建时间",
"sort_updated": "更新时间",
"sort_title": "标题",
"card_priority": "优先级",
"card_description": "描述",
Expand Down
4 changes: 2 additions & 2 deletions server/cmd/multica/cmd_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ var validIssuePriorities = []string{
// always sorted ascending (the board's manual drag order), so --direction is
// only meaningful for the other columns.
var validIssueSortColumns = []string{
"position", "title", "created_at", "start_date", "due_date", "priority",
"position", "title", "created_at", "updated_at", "start_date", "due_date", "priority",
}

// directionalIssueSortColumns are the sort keys for which --direction is
Expand Down Expand Up @@ -444,7 +444,7 @@ func init() {
issueListCmd.Flags().StringSlice("metadata", nil, "Filter by metadata key=value (repeatable; combined with AND). Value is JSON-parsed: 'true'/'false' → bool, numbers → number, otherwise string. Wrap as '\"42\"' to force a string when the value would otherwise sniff as a number.")
issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return")
issueListCmd.Flags().Int("offset", 0, "Number of issues to skip (for pagination)")
issueListCmd.Flags().String("sort", "", "Sort column: position (default, manual board order), title, created_at, start_date, due_date, priority")
issueListCmd.Flags().String("sort", "", "Sort column: position (default, manual board order), title, created_at, updated_at, start_date, due_date, priority")
issueListCmd.Flags().String("direction", "", "Sort direction (asc or desc); requires --sort to be a non-position column (position is always ascending)")

// issue get
Expand Down
12 changes: 6 additions & 6 deletions server/cmd/multica/cmd_issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2892,17 +2892,17 @@ func TestRunIssueListSendsSortAndDirection(t *testing.T) {

t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_TOKEN", "mat_test-token")

cmd := newIssueListTestCmd()
_ = cmd.Flags().Set("output", "json")
_ = cmd.Flags().Set("sort", "created_at")
_ = cmd.Flags().Set("sort", "updated_at")
_ = cmd.Flags().Set("direction", "DESC")
if err := runIssueList(cmd, nil); err != nil {
t.Fatalf("runIssueList: %v", err)
}
if got := gotQuery.Get("sort"); got != "created_at" {
t.Fatalf("sort query = %q, want created_at", got)
if got := gotQuery.Get("sort"); got != "updated_at" {
t.Fatalf("sort query = %q, want updated_at", got)
}
if got := gotQuery.Get("direction"); got != "desc" {
t.Fatalf("direction query = %q, want desc (lower-cased)", got)
Expand All @@ -2912,7 +2912,7 @@ func TestRunIssueListSendsSortAndDirection(t *testing.T) {
func TestRunIssueListRejectsInvalidSortAndDirection(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_TOKEN", "mat_test-token")

cmd := newIssueListTestCmd()
_ = cmd.Flags().Set("sort", "nonsense")
Expand All @@ -2938,7 +2938,7 @@ func TestRunIssueListRejectsInvalidSortAndDirection(t *testing.T) {
func TestRunIssueListRejectsDirectionWithoutDirectionalSort(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_TOKEN", "mat_test-token")

cases := []struct {
name string
Expand Down
4 changes: 2 additions & 2 deletions server/internal/handler/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
sortIsProperty := false
if s := r.URL.Query().Get("sort"); s != "" {
switch s {
case "position", "title", "created_at", "start_date", "due_date":
case "position", "title", "created_at", "updated_at", "start_date", "due_date":
sortCol = s
case "priority":
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
Expand Down Expand Up @@ -1539,7 +1539,7 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) {
sortIsProperty := false
if s := r.URL.Query().Get("sort"); s != "" {
switch s {
case "position", "title", "created_at", "start_date", "due_date":
case "position", "title", "created_at", "updated_at", "start_date", "due_date":
sortCol = s
case "priority":
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
Expand Down
103 changes: 103 additions & 0 deletions server/internal/handler/issue_updated_sort_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestIssueListsSortByUpdatedAt(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()

var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, fmt.Sprintf("Updated sort %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})

insertIssue := func(title string, updatedAt time.Time) string {
t.Helper()
var number int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 1
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&number); err != nil {
t.Fatalf("next issue number: %v", err)
}

var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
position, number, project_id, updated_at
)
VALUES ($1, $2, 'todo', 'none', 'member', $3, 'member', $3, 0, $4, $5, $6)
RETURNING id
`, testWorkspaceID, title, testUserID, number, projectID, updatedAt).Scan(&id); err != nil {
t.Fatalf("create issue %q: %v", title, err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id)
})
return id
}

olderID := insertIssue("Updated sort older", time.Date(2026, 7, 15, 10, 0, 0, 0, time.UTC))
newerID := insertIssue("Updated sort newer", time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC))

flat := httptest.NewRecorder()
testHandler.ListIssues(flat, newRequest("GET", fmt.Sprintf(
"/api/issues?workspace_id=%s&project_id=%s&sort=updated_at&direction=desc",
testWorkspaceID,
projectID,
), nil))
if flat.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", flat.Code, flat.Body.String())
}
var flatResp struct {
Issues []IssueResponse `json:"issues"`
}
if err := json.NewDecoder(flat.Body).Decode(&flatResp); err != nil {
t.Fatalf("decode flat response: %v", err)
}
if len(flatResp.Issues) != 2 || flatResp.Issues[0].ID != newerID || flatResp.Issues[1].ID != olderID {
t.Fatalf("flat updated_at order = %#v, want [%s, %s]", flatResp.Issues, newerID, olderID)
}

grouped := httptest.NewRecorder()
testHandler.ListGroupedIssues(grouped, newRequest("GET", fmt.Sprintf(
"/api/issues/grouped?workspace_id=%s&group_by=assignee&project_ids=%s&sort=updated_at&direction=asc",
testWorkspaceID,
projectID,
), nil))
if grouped.Code != http.StatusOK {
t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", grouped.Code, grouped.Body.String())
}
var groupedResp GroupedIssuesResponse
if err := json.NewDecoder(grouped.Body).Decode(&groupedResp); err != nil {
t.Fatalf("decode grouped response: %v", err)
}
if len(groupedResp.Groups) != 1 ||
len(groupedResp.Groups[0].Issues) != 2 ||
groupedResp.Groups[0].Issues[0].ID != olderID ||
groupedResp.Groups[0].Issues[1].ID != newerID {
t.Fatalf("grouped updated_at order = %#v, want [%s, %s]", groupedResp.Groups, olderID, newerID)
}
}
Loading