Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

### 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
- `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

- `project view` now shows what Linear's project page shows: the long-form overview body (`content`), milestones with their status and progress, resources (`externalLinks`), documents, attachments, related projects with their dependency direction, labels, members, initiatives, and Linear's own progress percentage. Only `description` — the 255-character summary — was rendered before, so a project whose body was written with `project create --content-file` displayed nothing of it. A project reference can now be a UUID, slug ID, or exact name everywhere, including with `--web`/`--app`, and long output pages like `issue view` does (`--no-pager` to disable). `--json` keeps the GraphQL field names and the `{ nodes, pageInfo }` shape of every connection
- `project view` with no argument opens a searchable list of projects to pick from, scoped like `project list` — the configured team, or the whole workspace when no team is set. It only prompts when stdin and stdout are both terminals; piped, redirected, in CI, or with `--json` it says a project is required instead of hanging on a prompt nobody can answer
- `issue archive <id>` archives an issue through Linear's `issueArchive` mutation, distinct from `issue delete`, which trashes it. It resolves identifiers like the other issue commands, prompts with the identifier and title unless `--confirm`/`-y` is passed, reports an already-archived issue instead of silently succeeding, and takes `--bulk`, `--bulk-file`, and `--bulk-stdin` like `issue delete` ([#285](https://github.com/schpet/linear-cli/pull/285); thanks @martin-piliar for the command and the report in [#284](https://github.com/schpet/linear-cli/issues/284))
- `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment`. Documents take a UUID or slug, projects and initiatives a UUID, slug, or name; `add` takes `--body` or `--body-file`. Every comment `add`, including `issue comment add`, now takes `--reply-to <commentId>` to answer in a thread (`-p`/`--parent` remain aliases). Comment lists now fetch every page instead of stopping at 50, and their `--json` nodes, plus the comments in `issue view --json`, carry `quotedText` (the passage an inline comment is anchored to) alongside `parent.id` ([#230](https://github.com/schpet/linear-cli/issues/230))
- every command that takes a team now accepts its key, name, or UUID, resolved through one shared lookup: `team states`, `team members`, `team delete`, `label list/create/delete --team`, `cycle list/view --team`, `project list/create/update --team`, `document list/create/update --team`, and `issue query/mine/create/update --team`. Keys stay canonical and win over a same-spelled name; an unknown team errors with the list of valid keys instead of an empty result or a raw API error. Previously only keys worked, which is why [#276](https://github.com/schpet/linear-cli/issues/276) asked for `team list --json` as a name-to-key lookup
Expand All @@ -20,6 +23,8 @@

### Fixed

- a project name that matches more than one project is now rejected with both projects' IDs instead of silently resolving to whichever Linear returned first. Linear does not require project names to be unique, so this affected every command that accepts a project by name — `project view/update/delete`, `project comment`, `project-update`, `milestone`, and `issue create/update/query/mine --project`
- `project view` no longer prints the status line above the project title on a terminal (it was written straight to stdout before the rest of the document was assembled), no longer glues the icon to the name — `Project.icon` holds a Linear icon name such as `Rocket`, never an emoji, so `# Rocket Mobile launch` was being rendered — and no longer undercounts issues, which previously came from a single unpaginated page and so stopped at 50
- `linear api` help now labels its positional `[graphqlDocument]` instead of `[query]`, which read like a subcommand and invited `linear api query '...'` (rejected with "Too many arguments"). The description states that the document is the only argument and that `api` has no subcommands, and an `Examples:` section covers inline, stdin, file, variable, and `--paginate` forms. No parsing change ([#286](https://github.com/schpet/linear-cli/issues/286))
- an unknown document, project, initiative, or issue passed to `document view` or any `comment` command is reported as `<Type> not found: <reference>` instead of Linear's raw "Could not find referenced …" wording, and `document view` no longer exits with a stack trace for an unknown slug (its not-found branch re-threw instead of reporting, and was unreachable until the not-found detection was fixed)
- `cycle list` and `milestone list` now paginate instead of taking Linear's default page, so a team with more than 50 cycles or a project with more than 50 milestones is no longer silently truncated
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ linear user list --json # machine-readable output

```bash
linear project list # list projects
linear project view # view project details
linear project view # pick from a searchable list of projects
linear project view <projectId> # overview, milestones, resources, documents, related projects
linear project view "Mobile launch" # a UUID, slug ID, or exact name all work
linear project view <projectId> --json # project details as JSON
linear project create --name "API v2" --team ENG --content-file overview.md
linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com
Expand Down
7 changes: 7 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,16 @@ linear project list

#### view project details

Shows the project's overview body, milestones, resources, documents, attachments, related projects, latest status update, issue counts, and details. A project is a UUID, slug ID, or exact name.

```bash
linear project view PROJECT-ID
linear project view "Mobile launch"
linear project view PROJECT-ID --json
linear project view PROJECT-ID --no-pager

# with no argument, pick from a searchable list (terminal only)
linear project view
```

#### project comments
Expand Down
15 changes: 8 additions & 7 deletions skills/linear-cli/references/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Options:
Commands:

list - List projects
view, v <projectId> - View project details
view, v [projectId] - View project details
create - Create a new Linear project
update <projectId> - Update a Linear project
delete <projectId> - Delete (trash) a Linear project
Expand Down Expand Up @@ -232,17 +232,18 @@ Options:
> View project details

```
Usage: linear project view <projectId>
Usage: linear project view [projectId]

Description:

View project details

Options:

-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-w, --web - Open in web browser
-a, --app - Open in Linear.app
-j, --json - Output as JSON
-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-w, --web - Open in web browser
-a, --app - Open in Linear.app
-j, --json - Output as JSON
--no-pager - Disable automatic paging for long output
```
155 changes: 107 additions & 48 deletions src/commands/project/project-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import type {
ProjectStatusType,
} from "../../__codegen__/graphql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { getTimeAgo, padDisplay } from "../../utils/display.ts"
import {
getProjectPriorityLabel,
getTimeAgo,
padDisplay,
} from "../../utils/display.ts"
import { LINEAR_WEB_BASE_URL } from "../../const.ts"
import { getTeamKey, resolveTeam } from "../../utils/linear.ts"
import { getOption } from "../../config.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { handleError, ValidationError } from "../../utils/errors.ts"
import { CliError, handleError, ValidationError } from "../../utils/errors.ts"

const GetProjects = gql(`
query GetProjects($filter: ProjectFilter, $first: Int, $after: String) {
Expand All @@ -24,11 +28,13 @@ const GetProjects = gql(`
slugId
icon
color
sortOrder
status {
id
name
color
type
position
}
lead {
name
Expand Down Expand Up @@ -59,6 +65,100 @@ const GetProjects = gql(`
}
`)

/**
* Just the fields the display order is computed from. Narrower than the query
* node so the comparator states what it actually reads, and so tests can build
* ordering cases without standing up a whole project.
*/
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." },
)
}
}
}

/**
* Compare two `Float!` sort keys. A null or NaN key would make the comparator
* return NaN, which scrambles the listing in a way that is much harder to spot
* than an error.
*/
function compareNumericKey(a: number, b: number, field: string): number {
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw new CliError(
`Linear returned a non-numeric ${field} for a project.`,
{ suggestion: "Retry, or report this if it keeps happening." },
)
}
return a - b
}

/**
* Order projects the way Linear's own project list does.
*
* 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.
*/
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

const byName = a.name.localeCompare(b.name)
if (byName !== 0) return byName

return a.id.localeCompare(b.id)
}

export const listCommand = new Command()
.name("list")
.description("List projects")
Expand Down Expand Up @@ -183,30 +283,7 @@ export const listCommand = new Command()
return
}

// Sort projects logically by status then by relevant date
const statusOrder: Record<ProjectStatusType, number> = {
"started": 1,
"planned": 2,
"backlog": 3,
"paused": 4,
"completed": 5,
"canceled": 6,
}

projects = projects.sort((a, b) => {
// First sort by status type priority
const statusA =
statusOrder[a.status.type as keyof typeof statusOrder] || 999
const statusB =
statusOrder[b.status.type as keyof typeof statusOrder] || 999

if (statusA !== statusB) {
return statusA - statusB
}

// Then sort alphabetically by name
return a.name.localeCompare(b.name)
})
projects = [...projects].sort(compareProjectsForDisplay)

if (json) {
console.log(JSON.stringify(
Expand Down Expand Up @@ -265,21 +342,11 @@ export const listCommand = new Command()
...projects.map((project) => project.status.name.length),
)

// Calculate priority and health widths based on actual values
const priorityMap = {
0: "None",
1: "Urgent",
2: "High",
3: "Medium",
4: "Low",
}
const PRIORITY_WIDTH = Math.max(
8, // minimum width for "PRIORITY" header
...projects.map((project) => {
const priority =
priorityMap[project.priority as keyof typeof priorityMap] || "None"
return priority.length
}),
...projects.map((project) =>
getProjectPriorityLabel(project.priority).length
),
)
const HEALTH_WIDTH = Math.max(
6, // minimum width for "HEALTH" header
Expand Down Expand Up @@ -342,15 +409,7 @@ export const listCommand = new Command()

// Print each project
for (const project of projects) {
const priorityMap = {
0: "None",
1: "Urgent",
2: "High",
3: "Medium",
4: "Low",
}
const priority =
priorityMap[project.priority as keyof typeof priorityMap] || "None"
const priority = getProjectPriorityLabel(project.priority)
const health = project.health || "Unknown"
const lead = project.lead?.initials || "-"
const teams = project.teams.nodes.map((t) => t.key).join(",") || "-"
Expand Down
Loading
Loading