diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f473ef..89b35323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- `issue mine`, `issue query`, `issue start`, and `team states` now group statuses in the same order as the Linear app: by workflow state type, then by the team's configured position within that type. Issue listings previously ran the order backwards (canceled and done first), and every status list sorted on raw position alone, which stranded a late-positioned status such as an "In Review" at position 1002 after "Duplicate" instead of beside "In Progress" + +### Changed + +- when `--limit` truncates an issue listing, the retained issues are now the most actionable rather than the most recently closed. The Linear API cannot sort by a team's configured positions, so it still selects which issues are fetched; that selection changed from closed-first to open-first. A status this build does not recognize sorts after all known ones + ### Added - issue comment list --json now exposes stable author identity: `user.id`, `externalUser.id`, and a `botActor` object (`id`, `name`, `type`, `subType`) for comments posted by integrations. Display names are editable and can collide across a workspace — an external user's display name can even match a real member's — so programs consuming the JSON previously had nothing reliable to attribute a comment with diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 83141d04..3adcca24 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -141,6 +141,98 @@ export async function getIssueId( return data.issue?.id } +// Linear documents WorkflowState.position as ordering states "in ascending +// order of position within their type group", so the type group is the primary +// key and position only orders states inside it. Sorting on position alone +// strands a late-positioned state: this workspace has "In Review" at position +// 1002 with type "started", which belongs right after "In Progress" (2), not +// after "Duplicate" (5). +// ASSUMPTION: the schema documents these type strings and says position orders +// states within a type group, but it does not state that this listing order is +// the app's group order. It matches Linear's observed lifecycle ordering; if a +// future release contradicts it, this list is the single place to fix. +const WORKFLOW_STATE_TYPE_ORDER: readonly string[] = [ + "triage", + "backlog", + "unstarted", + "started", + "completed", + "canceled", + "duplicate", +] + +function compareWorkflowStateTypes(a: string, b: string): number { + const aRank = WORKFLOW_STATE_TYPE_ORDER.indexOf(a) + const bRank = WORKFLOW_STATE_TYPE_ORDER.indexOf(b) + // A type Linear adds later sorts after every known one, grouped by its own + // name. An unrecognized status is not a broken invariant and must not take + // down a listing, but it must not be promoted ahead of the known lifecycle + // either. + if (aRank === -1 && bRank === -1) return a.localeCompare(b) + if (aRank === -1) return 1 + if (bRank === -1) return -1 + return aRank - bRank +} + +function assertFinitePosition( + state: { name: string; position: number }, +): number { + // `WorkflowState.position` is `Float!`, so a non-finite value means the + // response (or a test fixture) is malformed. Crash rather than continue: a + // NaN comparator result reads as "equal" and would silently degrade the + // listing to some other order instead of failing. + if (!Number.isFinite(state.position)) { + throw new CliError( + `Workflow state "${state.name}" has no usable position`, + { suggestion: "This indicates a malformed Linear API response." }, + ) + } + return state.position +} + +/** Order two workflow states of the SAME team the way the Linear app does. */ +export function compareWorkflowStates( + a: { name: string; type: string; position: number }, + b: { name: string; type: string; position: number }, +): number { + return compareWorkflowStateTypes(a.type, b.type) || + assertFinitePosition(a) - assertFinitePosition(b) +} + +type IssueWorkflowFields = { + state: { name: string; type: string; position: number } + team: { key: string } +} + +/** + * Order issues by status the way the Linear app groups them. + * + * `position` is only meaningful within one team, so the key depends on scope: + * + * - Single-team results (every `issue mine`/`issue start` call, and a scoped + * `issue query`) sort by type group then configured position — exactly the + * app's status order. + * - Multi-team results sort by type group only. Ranking one team's position + * against another's compares unrelated numbers, and doing it per-pair would + * not even be transitive: with A(teamA,5), B(teamB,0), C(teamA,0) you would + * get A == B, B == C, yet A > C, which breaks the comparator contract. + * + * Either way ties fall through to a stable sort (guaranteed since ES2019), + * preserving the priority/manual ordering the server already applied — so a + * cross-team listing keeps its priority order within each status group. + */ +function sortIssuesByWorkflowState( + issues: T[], +): T[] { + const firstTeam = issues[0]?.team.key + const multiTeam = issues.some((issue) => issue.team.key !== firstTeam) + return issues.sort( + multiTeam + ? (a, b) => compareWorkflowStateTypes(a.state.type, b.state.type) + : (a, b) => compareWorkflowStates(a.state, b.state), + ) +} + export async function getWorkflowStates( teamKey: string, ) { @@ -161,10 +253,7 @@ export async function getWorkflowStates( const client = getGraphQLClient() const result = await client.request(query, { teamKey }) - return result.team.states.nodes.sort( - (a: { position: number }, b: { position: number }) => - a.position - b.position, - ) + return result.team.states.nodes.sort(compareWorkflowStates) } export type WorkflowState = Awaited< ReturnType @@ -588,6 +677,39 @@ export async function fetchParentIssueData(parentId: string): Promise< } } +/** + * The server-side sort for issue listings. + * + * The `workflowState` clause no longer decides display order — issues are + * reordered locally by `compareIssuesByWorkflowState` afterwards, because the + * API cannot sort by a team's configured state positions. It still decides + * which issues survive `--limit` truncation, and `Ascending` keeps open work + * ahead of terminal work there. Under `Descending` a truncated + * `--all-states` listing filled up with canceled issues before reaching any of + * the user's actual work. + */ +function getIssueSortPayload( + sort: "manual" | "priority", +): Array { + switch (sort) { + case "manual": + return [ + { workflowState: { order: "Ascending" } }, + { manual: { nulls: "last" as const, order: "Ascending" as const } }, + ] + case "priority": + return [ + { workflowState: { order: "Ascending" } }, + { priority: { nulls: "last" as const, order: "Descending" as const } }, + { manual: { nulls: "last" as const, order: "Ascending" as const } }, + ] + default: + throw new ValidationError(`Unknown sort type: ${sort}`, { + suggestion: "Use 'manual' or 'priority'", + }) + } +} + export async function fetchIssuesForState( teamKey: string, state: string[] | undefined, @@ -679,6 +801,7 @@ export async function fetchIssuesForState( name color type + position } cycle { id @@ -728,26 +851,7 @@ export async function fetchIssuesForState( } `) - let sortPayload: Array - switch (sort) { - case "manual": - sortPayload = [ - { workflowState: { order: "Descending" } }, - { manual: { nulls: "last" as const, order: "Ascending" as const } }, - ] - break - case "priority": - sortPayload = [ - { workflowState: { order: "Descending" } }, - { priority: { nulls: "last" as const, order: "Descending" as const } }, - { manual: { nulls: "last" as const, order: "Ascending" as const } }, - ] - break - default: - throw new ValidationError(`Unknown sort type: ${sort}`, { - suggestion: "Use 'manual' or 'priority'", - }) - } + const sortPayload = getIssueSortPayload(sort) const client = getGraphQLClient() @@ -777,9 +881,11 @@ export async function fetchIssuesForState( after = result.issues?.pageInfo?.endCursor } + // Slice first, then sort: the cutoff stays determined purely by the server's + // order, instead of depending on how far the last page happened to overfetch. return { issues: { - nodes: allIssues.slice(0, limit), + nodes: sortIssuesByWorkflowState(allIssues.slice(0, limit)), }, } } @@ -814,6 +920,7 @@ const queryIssuesQuery = gql(/* GraphQL */ ` name color type + position } assignee { id @@ -979,26 +1086,7 @@ export async function fetchIssuesForQuery( } const sort = options.sort ?? "priority" - let sortPayload: Array - switch (sort) { - case "manual": - sortPayload = [ - { workflowState: { order: "Descending" } }, - { manual: { nulls: "last" as const, order: "Ascending" as const } }, - ] - break - case "priority": - sortPayload = [ - { workflowState: { order: "Descending" } }, - { priority: { nulls: "last" as const, order: "Descending" as const } }, - { manual: { nulls: "last" as const, order: "Ascending" as const } }, - ] - break - default: - throw new ValidationError(`Unknown sort type: ${sort}`, { - suggestion: "Use 'manual' or 'priority'", - }) - } + const sortPayload = getIssueSortPayload(sort) const client = getGraphQLClient() const fetchAll = options.limit === 0 @@ -1035,8 +1123,13 @@ export async function fetchIssuesForQuery( } } + // pageInfo still describes the server's pagination order while the nodes are + // reordered locally; preserving the connection shape beats inventing + // client-side pagination metadata. return { - nodes: fetchAll ? allNodes : allNodes.slice(0, limit), + nodes: sortIssuesByWorkflowState( + fetchAll ? allNodes : allNodes.slice(0, limit), + ), pageInfo: lastPageInfo, } } diff --git a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap index e75423b2..911fb7e9 100644 --- a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap @@ -34,3 +34,18 @@ Options: stderr: "" `; + +snapshot[`Issue Mine Command - Groups Statuses In Linear's Order 1`] = ` +stdout: +"◌ ID TITLE LABELS B E STATE UPDATED +--- ENG-1 Someday - Backlog 155 days ago +--- ENG-5 Next up - Todo 155 days ago +--- ENG-3 Doing it - In Progress 155 days ago +--- ENG-2 Reviewing - In Review 155 days ago +--- ENG-4 Shipped - Done 155 days ago +--- ENG-6 Rejected work - Rejected 155 days ago +--- ENG-7 From a future status - Paused 155 days ago +" +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-query.test.ts.snap b/test/commands/issue/__snapshots__/issue-query.test.ts.snap index 1e19ff17..f0ae3aa5 100644 --- a/test/commands/issue/__snapshots__/issue-query.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-query.test.ts.snap @@ -59,7 +59,8 @@ stdout: "id": "state-1", "name": "In Progress", "color": "#f2c94c", - "type": "started" + "type": "started", + "position": 2 }, "assignee": { "id": "user-1", diff --git a/test/commands/issue/issue-list.test.ts b/test/commands/issue/issue-list.test.ts index 5c87e0f8..2a2d1e65 100644 --- a/test/commands/issue/issue-list.test.ts +++ b/test/commands/issue/issue-list.test.ts @@ -68,6 +68,7 @@ Deno.test("Issue List Command - Filter By Label", async () => { name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [{ diff --git a/test/commands/issue/issue-mine.test.ts b/test/commands/issue/issue-mine.test.ts index 12b9e905..e7d44309 100644 --- a/test/commands/issue/issue-mine.test.ts +++ b/test/commands/issue/issue-mine.test.ts @@ -67,6 +67,7 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [{ @@ -147,7 +148,7 @@ Deno.test("Issue Mine Command - Defaults To Priority Sort Without Flag Or Env Va queryName: "GetIssuesForState", variables: { sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { priority: { nulls: "last", order: "Descending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], @@ -168,6 +169,7 @@ Deno.test("Issue Mine Command - Defaults To Priority Sort Without Flag Or Env Va name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [] }, cycle: null, @@ -214,7 +216,7 @@ Deno.test("Issue Mine Command - Configured Sort Order Wins Over Default", async queryName: "GetIssuesForState", variables: { sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], }, @@ -234,6 +236,7 @@ Deno.test("Issue Mine Command - Configured Sort Order Wins Over Default", async name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [] }, cycle: null, @@ -311,7 +314,7 @@ Deno.test("Issue Mine Command - Defaults To Priority Sort When Nothing Is Config queryName: "GetIssuesForState", variables: { sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { priority: { nulls: "last", order: "Descending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], @@ -332,6 +335,7 @@ Deno.test("Issue Mine Command - Defaults To Priority Sort When Nothing Is Config name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [] }, cycle: null, @@ -506,6 +510,7 @@ Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { name: "Todo", color: "#e2e2e2", type: "unstarted", + position: 1, } const { cleanup } = await setupMockLinearServer([ @@ -703,6 +708,7 @@ Deno.test("Issue Mine Command - Shows Cycle Column", async () => { name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [{ @@ -737,6 +743,7 @@ Deno.test("Issue Mine Command - Shows Cycle Column", async () => { name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, labels: { nodes: [] }, cycle: { @@ -787,3 +794,222 @@ Deno.test("Issue Mine Command - Shows Cycle Column", async () => { await cleanup() } }) + +// The reported bug: `issue mine --all-states` grouped statuses in the reverse of +// the Linear app's order. The mock returns them scrambled, so the rendered order +// is entirely the CLI's doing. It proves three things at once: +// +// - type group is the primary key, so "In Review" (started, position 1002) +// lands right after "In Progress" (started, 2) rather than dead last; +// - position cannot promote a state across type groups, so "Rejected" +// (canceled, 0.5) stays at the bottom instead of jumping above the backlog; +// - a state type this build does not know ("onhold") sorts after every known +// one instead of being silently ranked first. +// +// `queryIncludes` pins the GraphQL selection: the mock echoes fixtures verbatim, +// so without it this would still pass if the query stopped requesting position. +await cliffySnapshotTest({ + name: "Issue Mine Command - Groups Statuses In Linear's Order", + meta: import.meta, + colors: false, + args: ["--all-states"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssuesForState", + queryIncludes: "position", + response: { + data: { + issues: { + nodes: [ + { + id: "issue-eng-6", + identifier: "ENG-6", + title: "Rejected work", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-rej", + name: "Rejected", + color: "#e2e2e2", + type: "canceled", + position: 0.5, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-2", + identifier: "ENG-2", + title: "Reviewing", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-rev", + name: "In Review", + color: "#e2e2e2", + type: "started", + position: 1002, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-4", + identifier: "ENG-4", + title: "Shipped", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-done", + name: "Done", + color: "#e2e2e2", + type: "completed", + position: 3, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-1", + identifier: "ENG-1", + title: "Someday", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-back", + name: "Backlog", + color: "#e2e2e2", + type: "backlog", + position: 0, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-7", + identifier: "ENG-7", + title: "From a future status", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-new", + name: "Paused", + color: "#e2e2e2", + type: "onhold", + position: 0, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-3", + identifier: "ENG-3", + title: "Doing it", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-prog", + name: "In Progress", + color: "#e2e2e2", + type: "started", + position: 2, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + { + id: "issue-eng-5", + identifier: "ENG-5", + title: "Next up", + priority: 0, + estimate: null, + assignee: { initials: "PS" }, + state: { + id: "s-todo", + name: "Todo", + color: "#e2e2e2", + type: "unstarted", + position: 1, + }, + labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-29T10:00:00.000Z", + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { NO_COLOR: "true", LINEAR_TEAM_ID: "ENG" }) + + try { + await mineCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/issue/issue-query.test.ts b/test/commands/issue/issue-query.test.ts index 47af1ff2..21c5daef 100644 --- a/test/commands/issue/issue-query.test.ts +++ b/test/commands/issue/issue-query.test.ts @@ -40,6 +40,7 @@ const mockIssueNode = { name: "In Progress", color: "#f2c94c", type: "started", + position: 2.0, }, assignee: { id: "user-1", @@ -68,6 +69,13 @@ const mockIssueNode = { inverseRelations: { nodes: [] }, } +// `SearchIssues` selects state { id name color type } without `position`, so the +// search fixture must not carry it — otherwise the search JSON snapshot would +// assert a field the command never requested. +const { position: _searchStatePosition, ...mockSearchState } = + mockIssueNode.state +const mockSearchIssueNode = { ...mockIssueNode, state: mockSearchState } + // Test JSON output with filter mode (issues() backend) await snapshotTest({ name: "Issue Query Command - JSON Output", @@ -91,7 +99,7 @@ await snapshotTest({ state: { type: { in: ["started"] } }, }, sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { priority: { nulls: "last", order: "Descending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], @@ -145,7 +153,7 @@ await snapshotTest({ data: { searchIssues: { nodes: [{ - ...mockIssueNode, + ...mockSearchIssueNode, metadata: { context: {}, score: 0.42 }, }], pageInfo: { hasNextPage: false, endCursor: "cursor-1" }, @@ -185,7 +193,7 @@ Deno.test("Issue Query Command - All Teams shows TEAM column", async () => { queryName: "GetIssuesForQuery", variables: { sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { priority: { nulls: "last", order: "Descending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], @@ -709,3 +717,128 @@ Deno.test("shouldShowDefaultTeamNote - full source matrix", () => { ) } }) + +// Cross-team results must not compare one team's state positions against +// another's — those numbers are team-local and unrelated. So a multi-team +// listing groups by type group only and otherwise preserves the server's +// ordering, which keeps priority order intact inside each group. +// +// Here APP's "Doing" sits at position 900 and ENG's "Backlog" at 7. Ranking +// those numbers globally would interleave the two teams incoherently and, worse, +// would reorder issues across teams by an arbitrary number rather than by +// priority. The expected result groups both `backlog` issues first (backlog +// precedes started in Linear's type order), then both `started` issues, with +// each group keeping the server's priority order. +Deno.test("Issue Query Command - Multi-team results group by type without comparing team-local positions", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssuesForQuery", + queryIncludes: "position", + response: { + data: { + issues: { + nodes: [ + { + ...mockIssueNode, + id: "i-eng-1", + identifier: "ENG-1", + priority: 1, + state: { + id: "s-eng-1", + name: "In Progress", + color: "#e2e2e2", + type: "started", + position: 2, + }, + team: { + id: "team-eng", + key: "ENG", + name: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + }, + { + ...mockIssueNode, + id: "i-app-1", + identifier: "APP-1", + priority: 2, + state: { + id: "s-app-1", + name: "Doing", + color: "#e2e2e2", + type: "started", + position: 900, + }, + team: { + id: "team-app", + key: "APP", + name: "APP", + cyclesEnabled: false, + activeCycle: null, + }, + }, + { + ...mockIssueNode, + id: "i-app-2", + identifier: "APP-2", + priority: 1, + state: { + id: "s-app-2", + name: "Icebox", + color: "#e2e2e2", + type: "backlog", + position: 0, + }, + team: { + id: "team-app", + key: "APP", + name: "APP", + cyclesEnabled: false, + activeCycle: null, + }, + }, + { + ...mockIssueNode, + id: "i-eng-2", + identifier: "ENG-2", + priority: 3, + state: { + id: "s-eng-2", + name: "Backlog", + color: "#e2e2e2", + type: "backlog", + position: 7, + }, + team: { + id: "team-eng", + key: "ENG", + name: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await queryCommand.parse(["--all-teams"]) + const order = logs.join("\n").split("\n") + .map((line) => line.match(/\b(?:ENG|APP)-\d+/)?.[0]) + .filter((id): id is string => id != null) + assertEquals(order, ["APP-2", "ENG-2", "ENG-1", "APP-1"]) + } finally { + logStub.restore() + await cleanup() + } +}) diff --git a/test/commands/issue/issue-start.test.ts b/test/commands/issue/issue-start.test.ts index d894f9f2..24f9b707 100644 --- a/test/commands/issue/issue-start.test.ts +++ b/test/commands/issue/issue-start.test.ts @@ -15,7 +15,7 @@ Deno.test("Issue Start Command - Does Not Require Sort Config", async () => { queryName: "GetIssuesForState", variables: { sort: [ - { workflowState: { order: "Descending" } }, + { workflowState: { order: "Ascending" } }, { priority: { nulls: "last", order: "Descending" } }, { manual: { nulls: "last", order: "Ascending" } }, ], diff --git a/test/commands/team/__snapshots__/team-states.test.ts.snap b/test/commands/team/__snapshots__/team-states.test.ts.snap index 8c6cfeb8..786f8be6 100644 --- a/test/commands/team/__snapshots__/team-states.test.ts.snap +++ b/test/commands/team/__snapshots__/team-states.test.ts.snap @@ -22,10 +22,14 @@ stderr: snapshot[`Team States Command - Table 1`] = ` stdout: "NAME TYPE +Triage triage Backlog backlog Todo unstarted In Progress started +In Review started Done completed +Rejected canceled +Duplicate duplicate " stderr: "" @@ -35,6 +39,12 @@ snapshot[`Team States Command - JSON 1`] = ` stdout: '{ "nodes": [ + { + "id": "s-triage", + "name": "Triage", + "type": "triage", + "position": 900 + }, { "id": "s-backlog", "name": "Backlog", @@ -53,11 +63,29 @@ stdout: "type": "started", "position": 2 }, + { + "id": "s-review", + "name": "In Review", + "type": "started", + "position": 1002 + }, { "id": "s-done", "name": "Done", "type": "completed", "position": 3 + }, + { + "id": "s-rejected", + "name": "Rejected", + "type": "canceled", + "position": 0.5 + }, + { + "id": "s-dupe", + "name": "Duplicate", + "type": "duplicate", + "position": 5 } ] } @@ -69,10 +97,14 @@ stderr: snapshot[`Team States Command - Configured Team Fallback 1`] = ` stdout: "NAME TYPE +Triage triage Backlog backlog Todo unstarted In Progress started +In Review started Done completed +Rejected canceled +Duplicate duplicate " stderr: "" diff --git a/test/commands/team/team-states.test.ts b/test/commands/team/team-states.test.ts index 6cd98602..81b4d7de 100644 --- a/test/commands/team/team-states.test.ts +++ b/test/commands/team/team-states.test.ts @@ -7,13 +7,26 @@ import { MockLinearServer } from "../../utils/mock_linear_server.ts" // Common Deno args for permissions const denoArgs = ["--allow-all", "--quiet"] -// Deliberately out of position order to prove the command sorts by position. +// Deliberately out of order to prove the command sorts states the way the Linear +// app does: by type group first, then by configured position inside that group. +// +// "In Review" is the case that matters. It is type `started` at position 1002, +// so a plain position sort drops it to the very end, after `Duplicate` — which +// is exactly what this command used to print. It belongs right after +// "In Progress". "Rejected" is the mirror image: a `canceled` state positioned +// at 0.5 must not be promoted above the backlog. const UNSORTED_STATES = { data: { team: { states: { nodes: [ { id: "s-done", name: "Done", type: "completed", position: 3 }, + { + id: "s-review", + name: "In Review", + type: "started", + position: 1002, + }, { id: "s-backlog", name: "Backlog", type: "backlog", position: 0 }, { id: "s-progress", @@ -21,7 +34,15 @@ const UNSORTED_STATES = { type: "started", position: 2, }, + { + id: "s-rejected", + name: "Rejected", + type: "canceled", + position: 0.5, + }, + { id: "s-dupe", name: "Duplicate", type: "duplicate", position: 5 }, { id: "s-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "s-triage", name: "Triage", type: "triage", position: 900 }, ], }, },