From 5a13fa1ec7df533036bdd7cd479c96c0cd7fd3b3 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Tue, 22 Sep 2026 21:01:34 -0700 Subject: [PATCH] Order project list by sortOrder, as Linear's app does #290 ordered `project list` by status-type flow rank, then status position, then sortOrder. That rule was reconstructed from the schema docs and never checked against the app, and it is wrong: with probe projects in five statuses, Linear's projects list shows every project strictly by sortOrder ascending, so a backlog project with a higher sortOrder sits after a canceled one rather than with the other backlog projects. Order by sortOrder, then name, then id. Drop the status-type ranking and the status.position key, and stop fetching status.position, which nothing else read. Keep the non-finite check on sortOrder so a null key errors instead of scrambling the list. The status-type ranking was also the only exhaustive check on ProjectStatusType here, so make the date column's switch exhaustive instead of letting an unknown type fall through to the backlog date. Two things the observation cannot settle: whether the app view had a customised grouping/ordering setting, and sortOrder vs prioritySortOrder, which were identical for every project observed. --- CHANGELOG.md | 2 +- src/commands/project/project-list.ts | 82 +++----- .../__snapshots__/project-list.test.ts.snap | 73 ++++--- test/commands/project/project-list.test.ts | 194 ++++++++++-------- 4 files changed, 171 insertions(+), 180 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1faf871..a6c21cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed -- `project list` now orders projects the way Linear's own project list does: by the status's place in the project flow (backlog, planned, in progress, paused, completed, canceled), then by the status's configured position within that category, then by the manual order projects are dragged into, with name and id only breaking ties. The previous order was a hardcoded one that put in-progress work first, ignored both the workspace's own status positions and the manual order entirely, and fell back to sorting by name. Note that this ordering is reconstructed from what Linear's schema documents about `ProjectStatus.position` and `Project.sortOrder` rather than observed in the app, and that `project list --json` now carries those two fields +- `project list` now orders projects the way Linear's own project list showed them: by `sortOrder` ascending, the manual order projects are dragged into, with status playing no part and name and id only breaking ties. The previous order was a hardcoded one that grouped by status type with in-progress work first, ignored the manual order entirely, and fell back to sorting by name. The rule comes from comparing the app's projects list against a workspace with projects in five different statuses; whether that view had a customised grouping or ordering setting is not yet confirmed, and `sortOrder` and `prioritySortOrder` were identical there, so the observation could not tell those two apart. `project list --json` now carries `sortOrder` - `issue archive` help, `docs/usage.md`, the README, and the linear-cli skill now explain that Linear archives closed issues automatically and offers no manual archive in its app or official MCP server, quoting and linking Linear's docs, so the command reads as an escape hatch rather than the normal way to retire an issue ### Added diff --git a/src/commands/project/project-list.ts b/src/commands/project/project-list.ts index 33e7f5b8..cc0ed9bf 100644 --- a/src/commands/project/project-list.ts +++ b/src/commands/project/project-list.ts @@ -2,10 +2,7 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { open } from "@opensrc/deno-open" import { gql } from "../../__codegen__/gql.ts" -import type { - GetProjectsQuery, - ProjectStatusType, -} from "../../__codegen__/graphql.ts" +import type { GetProjectsQuery } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getProjectPriorityLabel, @@ -34,7 +31,6 @@ const GetProjects = gql(` name color type - position } lead { name @@ -74,42 +70,6 @@ export interface ProjectDisplayOrderKey { id: string name: string sortOrder: number - status: { type: ProjectStatusType; position: number } -} - -/** - * Rank a project status by where its category sits in Linear's project flow. - * - * `ProjectStatusType`'s order in the SDL is alphabetical and so says nothing - * about the lifecycle; the flow order below is the one Linear lays its project - * statuses out in. The `switch` is exhaustive on purpose: a status type added - * to the schema should fail the type check here, where someone has to decide - * where it belongs, rather than silently sort to the end. - */ -function statusTypeRank(type: ProjectStatusType): number { - switch (type) { - case "backlog": - return 0 - case "planned": - return 1 - case "started": - return 2 - case "paused": - return 3 - case "completed": - return 4 - case "canceled": - return 5 - default: { - const unreachable: never = type - throw new CliError( - `Linear returned an unknown project status type: ${ - String(unreachable) - }`, - { suggestion: "Update the CLI, or report this if it persists." }, - ) - } - } } /** @@ -128,28 +88,24 @@ function compareNumericKey(a: number, b: number, field: string): number { } /** - * Order projects the way Linear's own project list does. + * Order projects the way Linear's own project list does: by `sortOrder` + * ascending, the manual order projects are dragged into, with status ignored. * - * Reconstructed from the schema rather than observed in the app: `position` is - * documented as ordering statuses "within its type group", so the type's place - * in the flow comes first and the configured position refines it, and - * `sortOrder` is documented as the manual order used in list views. Name and id - * only break ties, so the result is stable across runs. + * Observed in the app rather than read off the schema. In a workspace holding + * backlog, planned, in progress, completed and canceled projects, Linear's + * projects list showed every project strictly by `sortOrder`: backlog projects + * with a higher `sortOrder` sat after the canceled one instead of being grouped + * with the backlog project that led the list. Whether that view had customised + * grouping or ordering settings is not yet confirmed. `prioritySortOrder` + * matched `sortOrder` for every project there, so the observation cannot tell + * the two apart; `sortOrder` is the one the schema documents as the manual + * order used in list views. Name and id only break ties, so the result is + * stable across runs. */ export function compareProjectsForDisplay( a: ProjectDisplayOrderKey, b: ProjectDisplayOrderKey, ): number { - const byType = statusTypeRank(a.status.type) - statusTypeRank(b.status.type) - if (byType !== 0) return byType - - const byPosition = compareNumericKey( - a.status.position, - b.status.position, - "status position", - ) - if (byPosition !== 0) return byPosition - const byManualOrder = compareNumericKey(a.sortOrder, b.sortOrder, "sortOrder") if (byManualOrder !== 0) return byManualOrder @@ -324,8 +280,18 @@ export const listCommand = new Command() : `Created ${getTimeAgo(new Date(project.createdAt))}` case "backlog": case "paused": - default: return `Updated ${getTimeAgo(new Date(project.updatedAt))}` + default: { + // Exhaustive so a status type added to the schema fails the type + // check here instead of silently getting the backlog date. + const unreachable: never = project.status.type + throw new CliError( + `Linear returned an unknown project status type: ${ + String(unreachable) + }`, + { suggestion: "Update the CLI, or report this if it persists." }, + ) + } } } diff --git a/test/commands/project/__snapshots__/project-list.test.ts.snap b/test/commands/project/__snapshots__/project-list.test.ts.snap index a1241578..23c491bd 100644 --- a/test/commands/project/__snapshots__/project-list.test.ts.snap +++ b/test/commands/project/__snapshots__/project-list.test.ts.snap @@ -62,8 +62,7 @@ stdout: "id": "status-1", "name": "In Progress", "color": "#f59e0b", - "type": "started", - "position": 2 + "type": "started" }, "lead": { "name": "test.user", @@ -103,6 +102,39 @@ snapshot[`Project List Command - JSON Output With Pagination 1`] = ` stdout: '{ "nodes": [ + { + "id": "project-page1-1", + "name": "Alpha Project", + "description": "First page project", + "slugId": "alpha-proj", + "sortOrder": 900, + "icon": null, + "color": "#3b82f6", + "status": { + "id": "status-1", + "name": "In Progress", + "color": "#f59e0b", + "type": "started" + }, + "lead": null, + "priority": 2, + "health": "onTrack", + "startDate": null, + "targetDate": null, + "startedAt": null, + "completedAt": null, + "canceledAt": null, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-20T15:30:00Z", + "url": "https://linear.app/test/project/alpha-proj", + "teams": { + "nodes": [ + { + "key": "ENG" + } + ] + } + }, { "id": "project-page2-1", "name": "Beta Project", @@ -115,8 +147,7 @@ stdout: "id": "status-2", "name": "Planned", "color": "#6366f1", - "type": "planned", - "position": 1 + "type": "planned" }, "lead": { "name": "pat.planner", @@ -140,40 +171,6 @@ stdout: } ] } - }, - { - "id": "project-page1-1", - "name": "Alpha Project", - "description": "First page project", - "slugId": "alpha-proj", - "sortOrder": 900, - "icon": null, - "color": "#3b82f6", - "status": { - "id": "status-1", - "name": "In Progress", - "color": "#f59e0b", - "type": "started", - "position": 2 - }, - "lead": null, - "priority": 2, - "health": "onTrack", - "startDate": null, - "targetDate": null, - "startedAt": null, - "completedAt": null, - "canceledAt": null, - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-20T15:30:00Z", - "url": "https://linear.app/test/project/alpha-proj", - "teams": { - "nodes": [ - { - "key": "ENG" - } - ] - } } ], "pageInfo": { diff --git a/test/commands/project/project-list.test.ts b/test/commands/project/project-list.test.ts index 422aee6f..a908c934 100644 --- a/test/commands/project/project-list.test.ts +++ b/test/commands/project/project-list.test.ts @@ -5,7 +5,6 @@ import { listCommand, type ProjectDisplayOrderKey, } from "../../../src/commands/project/project-list.ts" -import type { ProjectStatusType } from "../../../src/__codegen__/graphql.ts" import { assertEquals, assertStringIncludes } from "@std/assert" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -53,7 +52,6 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", - position: 2, }, lead: { name: "jane.smith", @@ -91,7 +89,6 @@ await snapshotTest({ name: "Planned", color: "#6366f1", type: "planned", - position: 1, }, lead: { name: "alex.designer", @@ -128,7 +125,6 @@ await snapshotTest({ name: "Completed", color: "#059669", type: "completed", - position: 4, }, lead: null, priority: 4, @@ -281,7 +277,6 @@ await cliffySnapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", - position: 2, }, lead: { name: "test.user", @@ -359,7 +354,6 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", - position: 2, }, lead: { name: "alice", @@ -393,7 +387,6 @@ await snapshotTest({ name: "Planned", color: "#6366f1", type: "planned", - position: 1, }, lead: { name: "bob", @@ -448,7 +441,6 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", - position: 2, }, lead: { name: "carol", @@ -482,7 +474,6 @@ await snapshotTest({ name: "Completed", color: "#059669", type: "completed", - position: 4, }, lead: null, priority: 4, @@ -552,7 +543,6 @@ await cliffySnapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", - position: 2, }, lead: null, priority: 2, @@ -598,7 +588,6 @@ await cliffySnapshotTest({ name: "Planned", color: "#6366f1", type: "planned", - position: 1, }, lead: { name: "pat.planner", @@ -694,58 +683,60 @@ await cliffySnapshotTest({ // The two command-level ordering snapshots above are still `ignore: true` for a // pre-existing mock-server problem, so the ordering rule is exercised directly // here rather than going unverified. -Deno.test("project list orders projects the way Linear's project flow does", () => { +// +// The cases are the projects of a real workspace, with the order Linear's +// projects list showed for them. Their statuses are noted beside each one: +// status plays no part in the order, which a comparator that grouped by +// status would get wrong, since it would pull "E probe backlog" down to the +// other backlog projects and push the canceled project to the end. +Deno.test("project list orders projects by sortOrder the way Linear's project list does", () => { const project = ( id: string, name: string, - type: ProjectStatusType, - position: number, sortOrder: number, - ): ProjectDisplayOrderKey => ({ - id, - name, - status: { type, position }, - sortOrder, - }) + ): ProjectDisplayOrderKey => ({ id, name, sortOrder }) + + const observedOrder = [ + project("id-e", "E probe backlog", -9086), // backlog + project("id-d", "D probe planned", -8018), // planned + project("id-c", "C probe in progress", -7086), // started + project("id-b", "B probe completed", -6020), // completed + project("id-a", "A probe canceled", -4964), // canceled + project("id-triage", "Triage 221-225 2026-05-22", -3971), // backlog + project("id-qa", "QA Test Project", -2949), // backlog + project("id-pr-init", "PR Test Project with Initiative", -2032), // backlog + project("id-pr", "PR Test Project", -1019), // backlog + project("id-one", "Linear CLI One point oh", 20.21), // backlog + ] - // Deliberately scrambled, and covering every status type. + // Scramble so the input order cannot produce the result by accident. Sorting + // by name would also be wrong here: the probes are lettered against their + // sortOrder, so "A probe canceled" would come first. const scrambled = [ - project("id-canceled", "Canceled work", "canceled", 5, 0), - project("id-started-b", "Second in flight", "started", 2, 50), - project("id-backlog-late", "Later backlog status", "backlog", 1, 0), - project("id-completed", "Finished work", "completed", 4, 0), - project("id-started-a", "First in flight", "started", 2, 10), - project("id-paused", "On hold", "paused", 3, 0), - project("id-planned", "Planned work", "planned", 1, 0), - project("id-backlog-early", "Earlier backlog status", "backlog", 0, 999), + observedOrder[9], + observedOrder[4], + observedOrder[0], + observedOrder[7], + observedOrder[2], + observedOrder[5], + observedOrder[8], + observedOrder[1], + observedOrder[6], + observedOrder[3], ] const ordered = [...scrambled].sort(compareProjectsForDisplay) - assertEquals(ordered.map((p) => p.id), [ - // Status type first, in flow order. - // Within backlog, the status's own position wins over sortOrder: the - // earlier status sorts first even though its project's manual order is - // much later. - "id-backlog-early", - "id-backlog-late", - "id-planned", - // Within one status, the manual sortOrder decides. Alphabetically - // "First in flight" would come first either way, so the values are set so - // that only sortOrder produces this order. - "id-started-a", - "id-started-b", - "id-paused", - "id-completed", - "id-canceled", - ]) + assertEquals( + ordered.map((p) => p.id), + observedOrder.map((p) => p.id), + ) }) Deno.test("project list breaks exact ties by name and then id", () => { const tied = (id: string, name: string): ProjectDisplayOrderKey => ({ id, name, - status: { type: "backlog", position: 0 }, sortOrder: 1, }) @@ -758,39 +749,46 @@ Deno.test("project list breaks exact ties by name and then id", () => { assertEquals(ordered.map((p) => p.id), ["id-m", "id-a", "id-z"]) }) -// A `Float!` that arrives null would make the comparator return NaN and -// scramble the listing. It can only be constructed on the wire, not in a typed -// fixture, so it is exercised through the mock server. -Deno.test("project list reports a non-numeric sort key instead of scrambling the order", async () => { - const node = (id: string, name: string, sortOrder: number | null) => ({ - id, - name, - description: "", - slugId: id, - sortOrder, - icon: null, - color: "#3b82f6", - status: { - id: "status-1", - name: "Backlog", - color: "#94a3b8", - type: "backlog", - position: 0, - }, - lead: null, - priority: 0, - health: null, - startDate: null, - targetDate: null, - startedAt: null, - completedAt: null, - canceledAt: null, - createdAt: "2024-01-10T10:00:00Z", - updatedAt: "2024-01-20T15:30:00Z", - url: `https://linear.app/test/project/${id}`, - teams: { nodes: [{ key: "ENG" }] }, - }) +// Fixture for the wire-level failure tests below, which need values a typed +// fixture cannot hold. +const wireNode = ( + id: string, + name: string, + sortOrder: number | null, + statusType: string, +) => ({ + id, + name, + description: "", + slugId: id, + sortOrder, + icon: null, + color: "#3b82f6", + status: { + id: "status-1", + name: "Backlog", + color: "#94a3b8", + type: statusType, + }, + lead: null, + priority: 0, + health: null, + startDate: null, + targetDate: null, + startedAt: null, + completedAt: null, + canceledAt: null, + createdAt: "2024-01-10T10:00:00Z", + updatedAt: "2024-01-20T15:30:00Z", + url: `https://linear.app/test/project/${id}`, + teams: { nodes: [{ key: "ENG" }] }, +}) +// Run `project list --all-teams` against the given nodes and return the exit +// code and everything written to stderr. +async function runListCapturingFailure( + nodes: ReturnType[], +): Promise<{ exitCode: number | undefined; stderr: string }> { const server = new MockLinearServer([ { queryName: "GetProjects", @@ -798,7 +796,7 @@ Deno.test("project list reports a non-numeric sort key instead of scrambling the response: { data: { projects: { - nodes: [node("broken", "Broken", null), node("fine", "Fine", 2)], + nodes, pageInfo: { hasNextPage: false, endCursor: null }, }, }, @@ -807,16 +805,18 @@ Deno.test("project list reports a non-numeric sort key instead of scrambling the ]) const originalError = console.error + const originalLog = console.log const originalExit = Deno.exit const errors: string[] = [] let exitCode: number | undefined console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")) } - Deno.exit = ((code?: number) => { + console.log = () => {} + Deno.exit = (code?: number): never => { exitCode = code throw new Error("exit") - }) as typeof Deno.exit + } try { await server.start() @@ -827,12 +827,40 @@ Deno.test("project list reports a non-numeric sort key instead of scrambling the if (!(error instanceof Error) || error.message !== "exit") throw error } finally { console.error = originalError + console.log = originalLog Deno.exit = originalExit await server.stop() Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") Deno.env.delete("LINEAR_API_KEY") } + return { exitCode, stderr: errors.join("\n") } +} + +// A `Float!` that arrives null would make the comparator return NaN and +// scramble the listing. It can only be constructed on the wire, not in a typed +// fixture, so it is exercised through the mock server. +Deno.test("project list reports a non-numeric sort key instead of scrambling the order", async () => { + const { exitCode, stderr } = await runListCapturingFailure([ + wireNode("broken", "Broken", null, "backlog"), + wireNode("fine", "Fine", 2, "backlog"), + ]) + + assertEquals(exitCode, 1) + assertStringIncludes(stderr, "non-numeric sortOrder") +}) + +// Status no longer feeds the order, so the table's date column is the one place +// that still has to know every status type. One it has not been taught about +// errors rather than silently being shown as if it were backlog. +Deno.test("project list reports an unknown project status type", async () => { + const { exitCode, stderr } = await runListCapturingFailure([ + wireNode("odd", "Odd", 1, "someNewType"), + ]) + assertEquals(exitCode, 1) - assertStringIncludes(errors.join("\n"), "non-numeric sortOrder") + assertStringIncludes( + stderr, + "unknown project status type: someNewType", + ) })