From 96732a6a92190787262672ebc5f913aab1164dc9 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 17 Sep 2026 16:40:29 -0700 Subject: [PATCH 1/3] Show what Linear's project page shows in project view project view rendered a project's 255-character `description` summary and nothing else of its content, so a project whose body was written with `project create --content-file` displayed none of it. The long-form body lives in a separate field, `content`, that the query never selected. The same was true of everything hanging off the project: resources, documents, attachments, milestones, labels, members, initiatives and dependencies were all reachable from the API and none of them were asked for. Select them, and render the result as one document in the shape issue view uses: title and meta line, summary, overview, then a section per relationship, omitting any section whose connection came back empty. Connections are requested at Linear's 250 page cap and keep their pageInfo, so --json carries the real connection contract and a section that could not be shown in full says so rather than trailing off. Issues are the exception and are paginated to exhaustion, because their count is displayed and so is the one number that could be wrong -- it previously stopped at Linear's default first page of 50. Three rendering bugs went with it. The status line was written straight to stdout the moment stdout was a terminal, which put it above the title in the finished output. `Project.icon` holds a Linear icon name such as `Rocket` and never an emoji -- the API rejects emoji outright -- so prefixing the title with it produced `# Rocket Mobile launch`; it moves to a labelled row. And `ProjectMilestone.progress` arrives as 0-100 while the identically named `Project.progress` is a 0-1 ratio, so scaling both alike displayed a quarter-done milestone as 2500%. Resolving the argument through resolveProjectId means a project name works wherever a UUID does, including with --web and --app, which previously put the raw name into the URL. Milestones, resources and documents are sorted by sortOrder ascending, since Linear returns them descending but displays them the other way round. --- CHANGELOG.md | 3 + README.md | 3 +- docs/usage.md | 4 + src/commands/project/project-view.ts | 907 ++++++++++++++---- src/utils/display.ts | 18 + src/utils/linear.ts | 16 +- .../__snapshots__/project-view.test.ts.snap | 327 +++++-- test/commands/project/project-view.test.ts | 808 +++++++++++----- test/utils/linear.test.ts | 29 + 9 files changed, 1650 insertions(+), 465 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f96695e..bb2b4697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 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 - `issue archive ` 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 ` 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 @@ -20,6 +21,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 ` not found: ` 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 diff --git a/README.md b/README.md index 21eff0d7..0d372ee6 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ linear user list --json # machine-readable output ```bash linear project list # list projects -linear project view # view project details +linear project view # overview, milestones, resources, documents, related projects +linear project view "Mobile launch" # a UUID, slug ID, or exact name all work linear project view --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 diff --git a/docs/usage.md b/docs/usage.md index 702b87ae..2a9e6c55 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -365,9 +365,13 @@ 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 ``` #### project comments diff --git a/src/commands/project/project-view.ts b/src/commands/project/project-view.ts index a1011065..adeb84d3 100644 --- a/src/commands/project/project-view.ts +++ b/src/commands/project/project-view.ts @@ -1,57 +1,231 @@ import { Command } from "@cliffy/command" import { renderMarkdown } from "@littletof/charmd" +import type { Extension } from "@littletof/charmd" import { gql } from "../../__codegen__/gql.ts" +import type { GetProjectDetailsQuery } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" -import { formatRelativeTime } from "../../utils/display.ts" +import { + formatRelativeTime, + getProjectPriorityLabel, +} from "../../utils/display.ts" import { openProjectPage } from "../../utils/actions.ts" -import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { handleError, NotFoundError } from "../../utils/errors.ts" +import { resolveProjectId } from "../../utils/linear.ts" +import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" +import { + shouldEnableHyperlinks, + shouldShowSpinner, +} from "../../utils/hyperlink.ts" +import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts" +import { getOption } from "../../config.ts" +import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" + +/** + * Linear caps connection pages at 250. Every connection below is requested at + * that cap and keeps its `pageInfo`, so `--json` still carries the real + * connection contract and the rendered view can say out loud when a section was + * cut off. Only `issues` is paginated to exhaustion: it is the one connection + * whose *count* is displayed, so it is the one that can state a wrong number. + */ +const CONNECTION_PAGE_SIZE = 250 const GetProjectDetails = gql(` - query GetProjectDetails($id: String!) { + query GetProjectDetails($id: String!, $first: Int!) { project(id: $id) { id name + identifier description + content slugId icon color + progress + scope + url + priority + health + healthUpdatedAt + startDate + startDateResolution + targetDate + targetDateResolution + startedAt + completedAt + canceledAt + archivedAt + autoArchivedAt + createdAt + updatedAt status { id name color + type + position } creator { + id name displayName } lead { + id name displayName } - priority - health - startDate - targetDate - startedAt - completedAt - canceledAt - updatedAt - createdAt - url - teams { + teams(first: $first) { nodes { id key name } + pageInfo { + hasNextPage + endCursor + } } - issues { + labels(first: $first) { + nodes { + id + name + color + } + pageInfo { + hasNextPage + endCursor + } + } + members(first: $first) { + nodes { + id + name + displayName + } + pageInfo { + hasNextPage + endCursor + } + } + initiatives(first: $first) { + nodes { + id + name + url + } + pageInfo { + hasNextPage + endCursor + } + } + projectMilestones(first: $first) { + nodes { + id + name + description + targetDate + progress + status + sortOrder + } + pageInfo { + hasNextPage + endCursor + } + } + externalLinks(first: $first) { + nodes { + id + label + url + sortOrder + } + pageInfo { + hasNextPage + endCursor + } + } + documents(first: $first) { + nodes { + id + title + url + sortOrder + } + pageInfo { + hasNextPage + endCursor + } + } + attachments(first: $first) { + nodes { + id + title + subtitle + url + sourceType + } + pageInfo { + hasNextPage + endCursor + } + } + relations(first: $first) { + nodes { + id + type + anchorType + relatedAnchorType + projectMilestone { + id + name + } + relatedProject { + id + name + url + } + relatedProjectMilestone { + id + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + inverseRelations(first: $first) { + nodes { + id + type + anchorType + relatedAnchorType + projectMilestone { + id + name + } + project { + id + name + url + } + relatedProjectMilestone { + id + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + issues(first: $first) { nodes { id identifier title state { + id name type } @@ -67,6 +241,7 @@ const GetProjectDetails = gql(` health createdAt user { + id name displayName } @@ -75,190 +250,586 @@ const GetProjectDetails = gql(` } `) -export const viewCommand = new Command() - .name("view") - .description("View project details") - .alias("v") - .arguments("") - .option("-w, --web", "Open in web browser") - .option("-a, --app", "Open in Linear.app") - .option("-j, --json", "Output as JSON") - .action(async (options, projectId) => { - const { web, app, json } = options +const GetProjectIssuesPage = gql(` + query GetProjectIssuesPage($id: String!, $first: Int!, $after: String!) { + project(id: $id) { + id + issues(first: $first, after: $after) { + nodes { + id + identifier + title + state { + id + name + type + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) + +type ProjectDetails = NonNullable +type PageInfo = ProjectDetails["issues"]["pageInfo"] +type MilestoneNode = ProjectDetails["projectMilestones"]["nodes"][number] +type ExternalLinkNode = ProjectDetails["externalLinks"]["nodes"][number] +type DocumentNode = ProjectDetails["documents"]["nodes"][number] +type AttachmentNode = ProjectDetails["attachments"]["nodes"][number] +type RelationNode = ProjectDetails["relations"]["nodes"][number] +type InverseRelationNode = ProjectDetails["inverseRelations"]["nodes"][number] +type IssueNode = ProjectDetails["issues"]["nodes"][number] + +/** + * Fetch a project and exhaust its issue connection. + * + * A cursor that stops advancing means the API told us there is another page but + * gave us no way to ask for it. Looping on it would spin forever and silently + * returning what we have would under-report the issue counts, so both are + * refused outright. + */ +async function fetchProjectDetails( + projectId: string, + originalInput: string, +): Promise { + const client = getGraphQLClient() + const result = await client.request(GetProjectDetails, { + id: projectId, + first: CONNECTION_PAGE_SIZE, + }) - if (web || app) { - await openProjectPage(projectId, { app, web: !app }) - return + const project = result.project + if (!project) { + throw new NotFoundError("Project", originalInput) + } + + const issues: IssueNode[] = [...project.issues.nodes] + let pageInfo: PageInfo = project.issues.pageInfo + let cursor: string | null | undefined = pageInfo.endCursor + + while (pageInfo.hasNextPage) { + if (cursor == null) { + throw new CliError( + `Linear reported more issues for project ${project.name} but returned no cursor to fetch them.`, + { suggestion: "Retry, or report this if it keeps happening." }, + ) } - const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = !json && shouldShowSpinner() - const spinner = showSpinner ? new Spinner() : null - spinner?.start() + const page = await client.request(GetProjectIssuesPage, { + id: projectId, + first: CONNECTION_PAGE_SIZE, + after: cursor, + }) + if (!page.project) { + throw new NotFoundError("Project", originalInput) + } - try { - const client = getGraphQLClient() - const result = await client.request(GetProjectDetails, { id: projectId }) - spinner?.stop() + issues.push(...page.project.issues.nodes) + pageInfo = page.project.issues.pageInfo - const project = result.project - if (!project) { - throw new NotFoundError("Project", projectId) - } + if (pageInfo.hasNextPage && pageInfo.endCursor === cursor) { + throw new CliError( + `Linear returned the same issue cursor twice for project ${project.name}.`, + { suggestion: "Retry, or report this if it keeps happening." }, + ) + } + cursor = pageInfo.endCursor + } - if (json) { - console.log(JSON.stringify(project, null, 2)) - return - } + return { + ...project, + issues: { ...project.issues, nodes: issues, pageInfo }, + } +} - // Build the display - const lines: string[] = [] +/** + * The meta line lists its connections inline, with no room for the note below, + * so a truncated one is marked with a trailing ellipsis instead. Printing a + * partial list unmarked would read as the complete one. + */ +function joinConnection( + values: readonly string[], + pageInfo: PageInfo, +): string { + const joined = values.join(", ") + return pageInfo.hasNextPage ? `${joined}, …` : joined +} - // Title with icon and color - const icon = project.icon ? `${project.icon} ` : "" - lines.push(`# ${icon}${project.name}`) - lines.push("") +/** A connection that could not be shown in full should say so, not trail off. */ +function truncationNote(pageInfo: PageInfo): string { + return pageInfo.hasNextPage + ? `\n_…and more (showing the first ${CONNECTION_PAGE_SIZE})._\n` + : "" +} - // Basic info - lines.push(`**Slug:** ${project.slugId}`) - lines.push(`**URL:** ${project.url}`) +function displayName( + user: { name: string; displayName: string } | null | undefined, +): string | undefined { + if (user == null) return undefined + return user.displayName || user.name +} - // Status with color styling - const statusLine = `**Status:** ${project.status.name}` - if (Deno.stdout.isTerminal()) { - console.log(`%c${statusLine}%c`, `color: ${project.status.color}`, "") - } else { - lines.push(statusLine) - } +/** + * Linear stores a coarse date as an ordinary day plus a resolution, so printing + * the day alone would present "sometime in Q4" as a specific deadline. + */ +function formatProjectDate( + date: string | null | undefined, + resolution: string | null | undefined, +): string | undefined { + if (date == null) return undefined + return resolution == null ? date : `${date} (${resolution})` +} - // Priority - const priorityMap = { - 0: "None", - 1: "Urgent", - 2: "High", - 3: "Medium", - 4: "Low", - } - const priority = - priorityMap[project.priority as keyof typeof priorityMap] || "None" - lines.push(`**Priority:** ${priority}`) +/** `Project.progress` is a 0-1 ratio. */ +function formatRatioAsPercent(ratio: number): string { + return `${Math.round(ratio * 100)}%` +} - // Health - if (project.health) { - lines.push(`**Health:** ${project.health}`) - } +/** + * `ProjectMilestone.progress` is documented as "the progress %" and really does + * arrive as 0-100, unlike the identically named 0-1 ratio on `Project`. Scaling + * it like a ratio renders a quarter-done milestone as "2500%". + */ +function formatMilestonePercent(percent: number): string { + return `${Math.round(percent)}%` +} - // People - if (project.creator) { - lines.push( - `**Creator:** ${project.creator.displayName || project.creator.name}`, +/** + * Compare two `Float!` sort keys, refusing values the schema says cannot happen. + * A null or NaN key would make the comparator return NaN and scramble the + * section order, which is far harder to notice than an error. + */ +function compareSortOrder( + a: number, + b: number, + field: string, + projectName: string, +): number { + if (!Number.isFinite(a) || !Number.isFinite(b)) { + throw new CliError( + `Linear returned a non-numeric ${field} for project ${projectName}.`, + { suggestion: "Retry, or report this if it keeps happening." }, + ) + } + return a - b +} + +function bySortOrder( + nodes: readonly T[], + field: string, + projectName: string, +): T[] { + return [...nodes].sort((a, b) => + compareSortOrder(a.sortOrder, b.sortOrder, field, projectName) + ) +} + +function formatMilestonesAsMarkdown( + nodes: readonly MilestoneNode[], + pageInfo: PageInfo, + projectName: string, +): string { + if (nodes.length === 0) return "" + + let markdown = "\n\n## Milestones\n\n" + for ( + const milestone of bySortOrder(nodes, "milestone sortOrder", projectName) + ) { + const meta = [milestone.status, formatMilestonePercent(milestone.progress)] + if (milestone.targetDate != null) { + meta.push(`target ${milestone.targetDate}`) + } + markdown += `- **${milestone.name}** _[${meta.join(", ")}]_\n` + if (milestone.description) { + markdown += ` ${milestone.description.split("\n").join("\n ")}\n` + } + } + return (markdown + truncationNote(pageInfo)).trimEnd() +} + +function formatResourcesAsMarkdown( + nodes: readonly ExternalLinkNode[], + pageInfo: PageInfo, + projectName: string, +): string { + if (nodes.length === 0) return "" + + let markdown = "\n\n## Resources\n\n" + for (const link of bySortOrder(nodes, "resource sortOrder", projectName)) { + markdown += `- **${link.label}**: ${link.url}\n` + } + return (markdown + truncationNote(pageInfo)).trimEnd() +} + +function formatDocumentsAsMarkdown( + nodes: readonly DocumentNode[], + pageInfo: PageInfo, + projectName: string, +): string { + if (nodes.length === 0) return "" + + let markdown = "\n\n## Documents\n\n" + for (const doc of bySortOrder(nodes, "document sortOrder", projectName)) { + markdown += `- **${doc.title}**: ${doc.url}\n` + } + return (markdown + truncationNote(pageInfo)).trimEnd() +} + +function formatAttachmentsAsMarkdown( + nodes: readonly AttachmentNode[], + pageInfo: PageInfo, +): string { + if (nodes.length === 0) return "" + + let markdown = "\n\n## Attachments\n\n" + for (const attachment of nodes) { + const sourceLabel = attachment.sourceType + ? ` _[${attachment.sourceType}]_` + : "" + markdown += `- **${attachment.title}**: ${attachment.url}${sourceLabel}\n` + if (attachment.subtitle) { + markdown += ` _${attachment.subtitle}_\n` + } + } + return (markdown + truncationNote(pageInfo)).trimEnd() +} + +/** + * Linear models a project dependency entirely through its anchors: `type` only + * ever takes the value `dependency`, while `anchorType` and `relatedAnchorType` + * (`start`, `end`, or `milestone`) say which end of each project is tied to the + * other. `end -> start` is therefore "this must finish before that begins". + * + * `anchors` are always given from this project's point of view, so callers + * reading `inverseRelations` must swap them before calling. + */ +function describeRelation( + ownAnchor: string, + otherAnchor: string, +): string { + if (ownAnchor === "end" && otherAnchor === "start") return "Blocks" + if (ownAnchor === "start" && otherAnchor === "end") return "Blocked by" + return "Related to" +} + +function formatRelatedProjectsAsMarkdown( + outgoing: readonly RelationNode[], + outgoingPageInfo: PageInfo, + incoming: readonly InverseRelationNode[], + incomingPageInfo: PageInfo, +): string { + if (outgoing.length === 0 && incoming.length === 0) return "" + + const milestoneNote = ( + own: { name: string } | null | undefined, + other: { name: string } | null | undefined, + ): string => { + const parts: string[] = [] + if (own != null) parts.push(`from milestone ${own.name}`) + if (other != null) parts.push(`to milestone ${other.name}`) + return parts.length > 0 ? ` _(${parts.join(", ")})_` : "" + } + + let markdown = "\n\n## Related projects\n\n" + + for (const relation of outgoing) { + const label = describeRelation( + relation.anchorType, + relation.relatedAnchorType, + ) + markdown += + `- **${label}** ${relation.relatedProject.name}: ${relation.relatedProject.url}${ + milestoneNote( + relation.projectMilestone, + relation.relatedProjectMilestone, ) - } - if (project.lead) { - lines.push( - `**Lead:** ${project.lead.displayName || project.lead.name}`, + }\n` + } + + for (const relation of incoming) { + // The stored anchors belong to the other project, so swap them to describe + // the relationship from this project's side. + const label = describeRelation( + relation.relatedAnchorType, + relation.anchorType, + ) + markdown += + `- **${label}** ${relation.project.name}: ${relation.project.url}${ + milestoneNote( + relation.relatedProjectMilestone, + relation.projectMilestone, ) - } + }\n` + } - // Dates - if (project.startDate) { - lines.push(`**Start Date:** ${project.startDate}`) - } - if (project.targetDate) { - lines.push(`**Target Date:** ${project.targetDate}`) - } - if (project.startedAt) { - lines.push(`**Started At:** ${formatRelativeTime(project.startedAt)}`) - } - if (project.completedAt) { - lines.push( - `**Completed At:** ${formatRelativeTime(project.completedAt)}`, + return (markdown + truncationNote(outgoingPageInfo) + + truncationNote(incomingPageInfo)).trimEnd() +} + +const ISSUE_STATE_LABELS: Array<[string, string]> = [ + ["triage", "Triage"], + ["backlog", "Backlog"], + ["unstarted", "To Do"], + ["started", "In Progress"], + ["completed", "Completed"], + ["canceled", "Canceled"], +] + +function formatIssuesAsMarkdown(nodes: readonly IssueNode[]): string { + if (nodes.length === 0) return "" + + const counts = new Map() + for (const issue of nodes) { + counts.set(issue.state.type, (counts.get(issue.state.type) ?? 0) + 1) + } + + const parts: string[] = [`${nodes.length} total`] + for (const [type, label] of ISSUE_STATE_LABELS) { + const count = counts.get(type) + if (count != null && count > 0) { + parts.push(`${count} ${label.toLowerCase()}`) + } + counts.delete(type) + } + // Any state type Linear adds later still shows up rather than vanishing. + for (const [type, count] of counts) { + parts.push(`${count} ${type}`) + } + + return `\n\n## Issues\n\n${parts.join(" · ")}` +} + +function formatDetailsAsMarkdown(project: ProjectDetails): string { + const rows: string[] = [] + const push = (label: string, value: string | undefined) => { + if (value != null && value !== "") rows.push(`- **${label}:** ${value}`) + } + + push("Slug", project.slugId) + push("URL", project.url) + // `icon` holds a Linear icon name such as "Rocket", never an emoji — the API + // rejects emoji outright — so it belongs in a labelled row, not glued to the + // title where it reads as part of the project's name. + push("Icon", project.icon ?? undefined) + push("Creator", displayName(project.creator)) + push( + "Members", + project.members.nodes.length > 0 + ? joinConnection( + project.members.nodes.map((member) => + member.displayName || member.name + ), + project.members.pageInfo, + ) + : undefined, + ) + push("Scope", project.scope > 0 ? String(project.scope) : undefined) + push( + "Start date", + formatProjectDate(project.startDate, project.startDateResolution), + ) + push( + "Target date", + formatProjectDate(project.targetDate, project.targetDateResolution), + ) + if (project.startedAt) { + push("Started", formatRelativeTime(project.startedAt)) + } + if (project.completedAt) { + push("Completed", formatRelativeTime(project.completedAt)) + } + if (project.canceledAt) { + push("Canceled", formatRelativeTime(project.canceledAt)) + } + if (project.archivedAt) { + push( + "Archived", + project.autoArchivedAt + ? `${formatRelativeTime(project.archivedAt)} (automatically)` + : formatRelativeTime(project.archivedAt), + ) + } + if (project.healthUpdatedAt) { + push("Health updated", formatRelativeTime(project.healthUpdatedAt)) + } + push("Created", formatRelativeTime(project.createdAt)) + push("Updated", formatRelativeTime(project.updatedAt)) + + return `\n\n## Details\n\n${rows.join("\n")}` +} + +/** Build the whole view as one markdown document, in display order. */ +export function formatProjectAsMarkdown(project: ProjectDetails): string { + const title = project.identifier + ? `# ${project.name} [${project.identifier}]` + : `# ${project.name}` + + const metaParts: string[] = [ + `**Status:** ${project.status.name}`, + `**Priority:** ${getProjectPriorityLabel(project.priority)}`, + ] + if (project.health) { + metaParts.push(`**Health:** ${project.health}`) + } + const lead = displayName(project.lead) + metaParts.push(`**Lead:** ${lead != null ? `@${lead}` : "Unassigned"}`) + if (project.teams.nodes.length > 0) { + metaParts.push( + `**Teams:** ${ + joinConnection( + project.teams.nodes.map((team) => `${team.name} (${team.key})`), + project.teams.pageInfo, ) - } - if (project.canceledAt) { - lines.push( - `**Canceled At:** ${formatRelativeTime(project.canceledAt)}`, + }`, + ) + } + if (project.labels.nodes.length > 0) { + metaParts.push( + `**Labels:** ${ + joinConnection( + project.labels.nodes.map((label) => label.name), + project.labels.pageInfo, ) - } + }`, + ) + } + if (project.initiatives.nodes.length > 0) { + metaParts.push( + `**Initiatives:** ${ + joinConnection( + project.initiatives.nodes.map((initiative) => initiative.name), + project.initiatives.pageInfo, + ) + }`, + ) + } + // `progress` is Linear's estimate-weighted ratio, not completed-over-total + // issues, so it is reported on its own and never alongside an issue count + // that would imply it was the numerator. + metaParts.push(`**Progress:** ${formatRatioAsPercent(project.progress)}`) - // Teams - if (project.teams.nodes.length > 0) { - const teamList = project.teams.nodes - .map((team) => `${team.name} (${team.key})`) - .join(", ") - lines.push(`**Teams:** ${teamList}`) - } + let markdown = `${title}\n\n${metaParts.join(" | ")}` + + if (project.description) { + markdown += `\n\n${project.description}` + } + if (project.content) { + markdown += `\n\n## Overview\n\n${project.content}` + } - lines.push("") - lines.push(`**Created:** ${formatRelativeTime(project.createdAt)}`) - lines.push(`**Updated:** ${formatRelativeTime(project.updatedAt)}`) + markdown += formatMilestonesAsMarkdown( + project.projectMilestones.nodes, + project.projectMilestones.pageInfo, + project.name, + ) + markdown += formatResourcesAsMarkdown( + project.externalLinks.nodes, + project.externalLinks.pageInfo, + project.name, + ) + markdown += formatDocumentsAsMarkdown( + project.documents.nodes, + project.documents.pageInfo, + project.name, + ) + markdown += formatAttachmentsAsMarkdown( + project.attachments.nodes, + project.attachments.pageInfo, + ) + markdown += formatRelatedProjectsAsMarkdown( + project.relations.nodes, + project.relations.pageInfo, + project.inverseRelations.nodes, + project.inverseRelations.pageInfo, + ) - // Description - if (project.description) { - lines.push("") - lines.push("## Description") - lines.push("") - lines.push(project.description) + if (project.lastUpdate) { + const update = project.lastUpdate + const author = displayName(update.user) + markdown += `\n\n## Latest Update\n\n` + markdown += `**By:** ${author ?? "Unknown"}\n` + markdown += `**When:** ${formatRelativeTime(update.createdAt)}\n` + if (update.health) { + markdown += `**Health:** ${update.health}\n` + } + markdown += `\n${update.body}` + } + + markdown += formatIssuesAsMarkdown(project.issues.nodes) + markdown += formatDetailsAsMarkdown(project) + + return markdown +} + +export const viewCommand = new Command() + .name("view") + .description("View project details") + .alias("v") + .arguments("") + .option("-w, --web", "Open in web browser") + .option("-a, --app", "Open in Linear.app") + .option("-j, --json", "Output as JSON") + .option("--no-pager", "Disable automatic paging for long output") + .action(async (options, projectId) => { + const { web, app, json, pager } = options + const usePager = pager !== false + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = !json && shouldShowSpinner() + const spinner = showSpinner ? new Spinner() : null + + try { + // Resolving up front means a project name works everywhere the command + // accepts an identifier, rather than only on the paths that happen to hit + // the GraphQL `project(id:)` field. + const resolvedId = await resolveProjectId(projectId) + + if (web || app) { + await openProjectPage(resolvedId, { app, web: !app }) + return } - // Latest update - if (project.lastUpdate) { - lines.push("") - lines.push("## Latest Update") - lines.push("") - const update = project.lastUpdate - lines.push(`**By:** ${update.user.displayName || update.user.name}`) - lines.push(`**When:** ${formatRelativeTime(update.createdAt)}`) - if (update.health) { - lines.push(`**Health:** ${update.health}`) - } - lines.push("") - lines.push(update.body) + spinner?.start() + const project = await fetchProjectDetails(resolvedId, projectId) + spinner?.stop() + + if (json) { + console.log(JSON.stringify(project, null, 2)) + return } - // Issue summary - if (project.issues.nodes.length > 0) { - lines.push("") - lines.push("## Issues Summary") - lines.push("") - - const issuesByState = project.issues.nodes.reduce( - (acc: Record, issue) => { - const stateType = issue.state.type - if (!acc[stateType]) acc[stateType] = 0 - acc[stateType]++ - return acc - }, - {} as Record, - ) + const markdown = formatProjectAsMarkdown(project) - const total = project.issues.nodes.length - const completed = issuesByState.completed || 0 - const started = issuesByState.started || 0 - const unstarted = issuesByState.unstarted || 0 - const canceled = issuesByState.canceled || 0 - const backlog = issuesByState.backlog || 0 - const triage = issuesByState.triage || 0 - - lines.push(`**Total Issues:** ${total}`) - if (completed > 0) lines.push(`**Completed:** ${completed}`) - if (started > 0) lines.push(`**In Progress:** ${started}`) - if (unstarted > 0) lines.push(`**To Do:** ${unstarted}`) - if (backlog > 0) lines.push(`**Backlog:** ${backlog}`) - if (triage > 0) lines.push(`**Triage:** ${triage}`) - if (canceled > 0) lines.push(`**Canceled:** ${canceled}`) + if (!Deno.stdout.isTerminal()) { + console.log(markdown) + return } - const markdown = lines.join("\n") + const configuredHyperlinkFormat = getOption("hyperlink_format") + const extensions: Extension[] = + configuredHyperlinkFormat && shouldEnableHyperlinks() + ? [createHyperlinkExtension(configuredHyperlinkFormat)] + : [] - if (Deno.stdout.isTerminal()) { - const terminalWidth = Deno.consoleSize().columns - console.log(renderMarkdown(markdown, { lineWidth: terminalWidth })) + const { columns: terminalWidth } = Deno.consoleSize() + const rendered = renderMarkdown(markdown, { + lineWidth: terminalWidth, + extensions, + }) + const outputLines = rendered.split("\n") + + if (shouldUsePager(outputLines, usePager)) { + await pipeToUserPager(rendered) } else { - console.log(markdown) + console.log(rendered) } } catch (error) { spinner?.stop() - handleError(error, "Failed to fetch project details") + handleError(error, "Failed to view project") } }) diff --git a/src/utils/display.ts b/src/utils/display.ts index c633c11c..e1260e0a 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -167,3 +167,21 @@ export function formatRelativeTime(dateString: string): string { return commentDate.toLocaleDateString() } } + +const PROJECT_PRIORITY_LABELS: Record = { + 0: "None", + 1: "Urgent", + 2: "High", + 3: "Medium", + 4: "Low", +} + +/** + * Linear reports project priority as the same 0-4 scale it uses for issues, but + * project surfaces label it with words rather than the bar glyphs + * [[getPriorityDisplay]] renders. Unknown values fall through to the number so a + * new priority level is visible rather than silently displayed as "None". + */ +export function getProjectPriorityLabel(priority: number): string { + return PROJECT_PRIORITY_LABELS[priority] ?? String(priority) +} diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 2577a4cd..70f37866 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -1638,7 +1638,21 @@ export async function getProjectIdByName( } `) const nameData = await client.request(nameQuery, { name: input }) - const nameMatch = nameData.projects?.nodes[0]?.id + const nameMatches = nameData.projects?.nodes ?? [] + if (nameMatches.length > 1) { + // Linear does not require project names to be unique, so picking the first + // match would act on an arbitrary project the caller never named. + throw new ValidationError( + `Project "${input}" is ambiguous; it matches ${nameMatches.length} projects:\n${ + nameMatches.map((project) => ` ${project.id}`).join("\n") + }`, + { + suggestion: + "Pass the project's UUID or slug ID instead. `linear project list` shows both.", + }, + ) + } + const nameMatch = nameMatches[0]?.id if (nameMatch) return nameMatch const slugQuery = gql(/* GraphQL */ ` diff --git a/test/commands/project/__snapshots__/project-view.test.ts.snap b/test/commands/project/__snapshots__/project-view.test.ts.snap index 23f40a7c..f7f948bd 100644 --- a/test/commands/project/__snapshots__/project-view.test.ts.snap +++ b/test/commands/project/__snapshots__/project-view.test.ts.snap @@ -11,10 +11,11 @@ Description: Options: - -h, --help - Show this help. - -w, --web - Open in web browser - -a, --app - Open in Linear.app - -j, --json - Output as JSON + -h, --help - Show this help. + -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 " stderr: @@ -23,64 +24,72 @@ stderr: snapshot[`Project View Command - With Project Details 1`] = ` stdout: -"# 🔐 Authentication System Redesign +"# Authentication System Redesign [PRJ-12] -**Slug:** auth-redesign-2024 -**URL:** https://linear.app/acme/project/auth-redesign-2024 -**Status:** In Progress -**Priority:** High -**Health:** onTrack -**Creator:** John Admin -**Lead:** Jane Lead -**Start Date:** 2024-01-15 -**Target Date:** 2024-04-30 -**Started At:** 1/16/2024 -**Teams:** Backend Team (BACKEND), Security Team (SECURITY) +**Status:** In Progress | **Priority:** High | **Health:** atRisk | **Lead:** @Jane Lead | **Teams:** Backend Team (BACKEND), Security Team (SECURITY) | **Labels:** security, q1 | **Initiatives:** Platform Hardening | **Progress:** 31% -**Created:** 1/10/2024 -**Updated:** 1/25/2024 +Overhaul of the authentication system. -## Description +## Overview -Complete overhaul of the authentication system to improve security and user experience. +# Goals -## Goals -- Implement OAuth 2.0 / OpenID Connect +- Implement OAuth 2.0 - Add multi-factor authentication -- Improve password reset flow -- Add social login options -## Technical Requirements -- JWT tokens with proper rotation -- Rate limiting on auth endpoints -- Audit logging for security events -- GDPR compliance for user data + +## Milestones + +- **Design complete** _[next, 25%, target 2024-02-01]_ + Sign-off from security. +- **Rollout** _[unstarted, 0%, target 2024-03-15]_ + +## Resources + +- **Design doc**: https://example.com/design +- **Figma board**: https://figma.com/file/abc + +## Documents + +- **Rollout plan**: https://linear.app/acme/document/rollout-plan +- **Threat model**: https://linear.app/acme/document/threat-model + +## Attachments + +- **Build #4127**: https://ci.example.com/builds/4127 _[ci]_ + _passing in 3m12s_ + +## Related projects + +- **Blocks** Session Service: https://linear.app/acme/project/session-service +- **Blocked by** Identity Provider Swap: https://linear.app/acme/project/idp-swap ## Latest Update **By:** Jane Lead **When:** 1/22/2024 -**Health:** onTrack +**Health:** atRisk -Great progress this week! The OAuth implementation is nearly complete and we're on track for our Q1 delivery. The team has been collaborating well across backend and security concerns. +OAuth implementation is nearly complete. -**This week's highlights:** -- OAuth 2.0 flow implementation 80% complete -- MFA design reviews completed -- Security penetration testing scheduled +## Issues -**Next week:** -- Complete OAuth testing -- Begin MFA implementation -- Finalize UI designs +3 total · 1 to do · 1 in progress · 1 completed -## Issues Summary +## Details -**Total Issues:** 4 -**Completed:** 1 -**In Progress:** 1 -**To Do:** 1 -**Canceled:** 1 +- **Slug:** auth-redesign-2024 +- **URL:** https://linear.app/acme/project/auth-redesign-2024 +- **Icon:** Rocket +- **Creator:** John Admin +- **Members:** Jane Lead +- **Scope:** 8 +- **Start date:** 2024-01-15 +- **Target date:** 2024-03-31 (quarter) +- **Started:** 1/15/2024 +- **Health updated:** 1/24/2024 +- **Created:** 1/10/2024 +- **Updated:** 1/25/2024 " stderr: "" @@ -90,13 +99,14 @@ snapshot[`Project View Command - Minimal Project 1`] = ` stdout: "# Simple Project -**Slug:** simple -**URL:** https://linear.app/acme/project/simple -**Status:** Backlog -**Priority:** None +**Status:** Backlog | **Priority:** None | **Lead:** Unassigned | **Progress:** 0% + +## Details -**Created:** 1/20/2024 -**Updated:** 1/20/2024 +- **Slug:** simple +- **URL:** https://linear.app/acme/project/simple +- **Created:** 1/20/2024 +- **Updated:** 1/20/2024 " stderr: "" @@ -105,43 +115,196 @@ stderr: snapshot[`Project View Command - JSON Output 1`] = ` stdout: '{ - "id": "project-123", + "id": "85d3dad6-136e-49ff-9593-33dc4b22b5ee", "name": "Authentication System Redesign", - "description": "Overhaul auth.", + "identifier": "PRJ-12", + "description": "Overhaul of the authentication system.", + "content": "# Goals\\\\n\\\\n- Implement OAuth 2.0\\\\n- Add multi-factor authentication\\\\n", "slugId": "auth-redesign-2024", - "icon": "🔐", + "icon": "Rocket", "color": "#3b82f6", + "progress": 0.3125, + "scope": 8, + "url": "https://linear.app/acme/project/auth-redesign-2024", + "priority": 2, + "health": "atRisk", + "healthUpdatedAt": "2024-01-24T10:00:00Z", + "startDate": "2024-01-15", + "startDateResolution": null, + "targetDate": "2024-03-31", + "targetDateResolution": "quarter", + "startedAt": "2024-01-15T09:00:00Z", + "completedAt": null, + "canceledAt": null, + "archivedAt": null, + "autoArchivedAt": null, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-25T14:30:00Z", "status": { "id": "status-1", "name": "In Progress", - "color": "#f59e0b" + "color": "#f59e0b", + "type": "started", + "position": 2 }, "creator": { + "id": "u1", "name": "john.admin", "displayName": "John Admin" }, "lead": { + "id": "u2", "name": "jane.lead", "displayName": "Jane Lead" }, - "priority": 2, - "health": "onTrack", - "startDate": "2024-01-15", - "targetDate": "2024-03-31", - "startedAt": "2024-01-15T09:00:00Z", - "completedAt": null, - "canceledAt": null, - "updatedAt": "2024-01-25T14:30:00Z", - "createdAt": "2024-01-10T10:00:00Z", - "url": "https://linear.app/acme/project/auth-redesign-2024", "teams": { "nodes": [ { "id": "team-1", "key": "BACKEND", "name": "Backend Team" + }, + { + "id": "team-2", + "key": "SECURITY", + "name": "Security Team" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "labels": { + "nodes": [ + { + "id": "l1", + "name": "security", + "color": "#22c55e" + }, + { + "id": "l2", + "name": "q1", + "color": "#f97316" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "members": { + "nodes": [ + { + "id": "u2", + "name": "jane.lead", + "displayName": "Jane Lead" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "initiatives": { + "nodes": [ + { + "id": "i1", + "name": "Platform Hardening", + "url": "https://linear.app/acme/initiative/platform-hardening" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "projectMilestones": { + "nodes": [ + { + "id": "m2", + "name": "Rollout", + "description": null, + "targetDate": "2024-03-15", + "progress": 0, + "status": "unstarted", + "sortOrder": 900 + }, + { + "id": "m1", + "name": "Design complete", + "description": "Sign-off from security.", + "targetDate": "2024-02-01", + "progress": 25, + "status": "next", + "sortOrder": -3 + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "externalLinks": { + "nodes": [ + { + "id": "e2", + "label": "Figma board", + "url": "https://figma.com/file/abc", + "sortOrder": 900 + }, + { + "id": "e1", + "label": "Design doc", + "url": "https://example.com/design", + "sortOrder": 4 } - ] + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "documents": { + "nodes": [ + { + "id": "d2", + "title": "Threat model", + "url": "https://linear.app/acme/document/threat-model", + "sortOrder": 20 + }, + { + "id": "d1", + "title": "Rollout plan", + "url": "https://linear.app/acme/document/rollout-plan", + "sortOrder": 10 + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "attachments": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "relations": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "inverseRelations": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } }, "issues": { "nodes": [ @@ -150,22 +313,44 @@ stdout: "identifier": "AUTH-101", "title": "Implement OAuth 2.0 flow", "state": { + "id": "s1", "name": "In Progress", "type": "started" } + }, + { + "id": "issue-2", + "identifier": "AUTH-102", + "title": "Add MFA support", + "state": { + "id": "s2", + "name": "To Do", + "type": "unstarted" + } + }, + { + "id": "issue-3", + "identifier": "AUTH-103", + "title": "Design new login UI", + "state": { + "id": "s3", + "name": "Done", + "type": "completed" + } } ], "pageInfo": { - "hasNextPage": true, - "endCursor": "issues-cursor-1" + "hasNextPage": false, + "endCursor": null } }, "lastUpdate": { "id": "update-1", - "body": "On track.", - "health": "onTrack", - "createdAt": "2024-01-25T14:30:00Z", + "body": "OAuth implementation is nearly complete.", + "health": "atRisk", + "createdAt": "2024-01-22T16:00:00Z", "user": { + "id": "u2", "name": "jane.lead", "displayName": "Jane Lead" } diff --git a/test/commands/project/project-view.test.ts b/test/commands/project/project-view.test.ts index bce60167..cd6922cd 100644 --- a/test/commands/project/project-view.test.ts +++ b/test/commands/project/project-view.test.ts @@ -1,10 +1,282 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" import { viewCommand } from "../../../src/commands/project/project-view.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" // Common Deno args for permissions const denoArgs = ["--allow-all", "--quiet"] +// `resolveProjectId` short-circuits on a UUID, so tests that use one exercise +// the view without also having to mock the name/slug lookup queries. +const PROJECT_UUID = "85d3dad6-136e-49ff-9593-33dc4b22b5ee" +const MINIMAL_UUID = "b36001b3-0cfb-4b7e-8c62-4750c628c387" + +const emptyConnection = { nodes: [], pageInfo: emptyPageInfo() } + +function emptyPageInfo() { + return { hasNextPage: false, endCursor: null } +} + +function connection(nodes: unknown[], hasNextPage = false) { + return { + nodes, + pageInfo: { + hasNextPage, + endCursor: hasNextPage ? "cursor-1" : null, + }, + } +} + +/** + * A project with every optional relationship populated. Individual tests + * override only the parts they are about, so a new field added to the query + * does not have to be repeated across every fixture. + */ +function richProject(overrides: Record = {}) { + return { + id: PROJECT_UUID, + name: "Authentication System Redesign", + identifier: "PRJ-12", + description: "Overhaul of the authentication system.", + content: + "# Goals\n\n- Implement OAuth 2.0\n- Add multi-factor authentication\n", + slugId: "auth-redesign-2024", + icon: "Rocket", + color: "#3b82f6", + progress: 0.3125, + scope: 8, + url: "https://linear.app/acme/project/auth-redesign-2024", + priority: 2, + health: "atRisk", + healthUpdatedAt: "2024-01-24T10:00:00Z", + startDate: "2024-01-15", + startDateResolution: null, + targetDate: "2024-03-31", + targetDateResolution: "quarter", + startedAt: "2024-01-15T09:00:00Z", + completedAt: null, + canceledAt: null, + archivedAt: null, + autoArchivedAt: null, + createdAt: "2024-01-10T10:00:00Z", + updatedAt: "2024-01-25T14:30:00Z", + status: { + id: "status-1", + name: "In Progress", + color: "#f59e0b", + type: "started", + position: 2, + }, + creator: { id: "u1", name: "john.admin", displayName: "John Admin" }, + lead: { id: "u2", name: "jane.lead", displayName: "Jane Lead" }, + teams: connection([ + { id: "team-1", key: "BACKEND", name: "Backend Team" }, + { id: "team-2", key: "SECURITY", name: "Security Team" }, + ]), + labels: connection([ + { id: "l1", name: "security", color: "#22c55e" }, + { id: "l2", name: "q1", color: "#f97316" }, + ]), + members: connection([ + { id: "u2", name: "jane.lead", displayName: "Jane Lead" }, + ]), + initiatives: connection([ + { + id: "i1", + name: "Platform Hardening", + url: "https://linear.app/acme/initiative/platform-hardening", + }, + ]), + // Deliberately out of order: Linear returns these descending by sortOrder + // while its own UI shows them ascending. + projectMilestones: connection([ + { + id: "m2", + name: "Rollout", + description: null, + targetDate: "2024-03-15", + progress: 0, + status: "unstarted", + sortOrder: 900, + }, + { + id: "m1", + name: "Design complete", + description: "Sign-off from security.", + targetDate: "2024-02-01", + // Milestone progress arrives as 0-100, not as a 0-1 ratio. + progress: 25, + status: "next", + sortOrder: -3, + }, + ]), + externalLinks: connection([ + { + id: "e2", + label: "Figma board", + url: "https://figma.com/file/abc", + sortOrder: 900, + }, + { + id: "e1", + label: "Design doc", + url: "https://example.com/design", + sortOrder: 4, + }, + ]), + documents: connection([ + { + id: "d2", + title: "Threat model", + url: "https://linear.app/acme/document/threat-model", + sortOrder: 20, + }, + { + id: "d1", + title: "Rollout plan", + url: "https://linear.app/acme/document/rollout-plan", + sortOrder: 10, + }, + ]), + attachments: connection([ + { + id: "a1", + title: "Build #4127", + subtitle: "passing in 3m12s", + url: "https://ci.example.com/builds/4127", + sourceType: "ci", + }, + ]), + relations: connection([ + { + id: "r1", + type: "dependency", + anchorType: "end", + relatedAnchorType: "start", + projectMilestone: null, + relatedProject: { + id: "p2", + name: "Session Service", + url: "https://linear.app/acme/project/session-service", + }, + relatedProjectMilestone: null, + }, + ]), + inverseRelations: connection([ + { + id: "r2", + type: "dependency", + anchorType: "end", + relatedAnchorType: "start", + projectMilestone: null, + project: { + id: "p3", + name: "Identity Provider Swap", + url: "https://linear.app/acme/project/idp-swap", + }, + relatedProjectMilestone: null, + }, + ]), + issues: connection([ + { + id: "issue-1", + identifier: "AUTH-101", + title: "Implement OAuth 2.0 flow", + state: { id: "s1", name: "In Progress", type: "started" }, + }, + { + id: "issue-2", + identifier: "AUTH-102", + title: "Add MFA support", + state: { id: "s2", name: "To Do", type: "unstarted" }, + }, + { + id: "issue-3", + identifier: "AUTH-103", + title: "Design new login UI", + state: { id: "s3", name: "Done", type: "completed" }, + }, + ]), + lastUpdate: { + id: "update-1", + body: "OAuth implementation is nearly complete.", + health: "atRisk", + createdAt: "2024-01-22T16:00:00Z", + user: { id: "u2", name: "jane.lead", displayName: "Jane Lead" }, + }, + ...overrides, + } +} + +function minimalProject(overrides: Record = {}) { + return { + id: MINIMAL_UUID, + name: "Simple Project", + identifier: null, + description: "", + content: null, + slugId: "simple", + icon: null, + color: "#64748b", + progress: 0, + scope: 0, + url: "https://linear.app/acme/project/simple", + priority: 0, + health: null, + healthUpdatedAt: null, + startDate: null, + startDateResolution: null, + targetDate: null, + targetDateResolution: null, + startedAt: null, + completedAt: null, + canceledAt: null, + archivedAt: null, + autoArchivedAt: null, + createdAt: "2024-01-20T12:00:00Z", + updatedAt: "2024-01-20T12:00:00Z", + status: { + id: "status-backlog", + name: "Backlog", + color: "#94a3b8", + type: "backlog", + position: 0, + }, + creator: null, + lead: null, + teams: emptyConnection, + labels: emptyConnection, + members: emptyConnection, + initiatives: emptyConnection, + projectMilestones: emptyConnection, + externalLinks: emptyConnection, + documents: emptyConnection, + attachments: emptyConnection, + relations: emptyConnection, + inverseRelations: emptyConnection, + issues: emptyConnection, + lastUpdate: null, + ...overrides, + } +} + +async function withMockServer( + responses: ConstructorParameters[0], + fn: (server: MockLinearServer) => Promise, +) { + const server = new MockLinearServer(responses) + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await fn(server) + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } +} + // Test help output await snapshotTest({ name: "Project View Command - Help Text", @@ -17,273 +289,361 @@ await snapshotTest({ }, }) -// Test with mock server - Project details +// Every section the command can render, in display order, with milestones and +// resources reordered out of the descending order Linear returns them in. await snapshotTest({ name: "Project View Command - With Project Details", meta: import.meta, colors: false, - args: ["project-123"], + args: [PROJECT_UUID], denoArgs, async fn() { - const server = new MockLinearServer([ + await withMockServer([ { queryName: "GetProjectDetails", - variables: { id: "project-123" }, - response: { - data: { - project: { - id: "project-123", - name: "Authentication System Redesign", - description: - "Complete overhaul of the authentication system to improve security and user experience.\n\n## Goals\n- Implement OAuth 2.0 / OpenID Connect\n- Add multi-factor authentication\n- Improve password reset flow\n- Add social login options\n\n## Technical Requirements\n- JWT tokens with proper rotation\n- Rate limiting on auth endpoints\n- Audit logging for security events\n- GDPR compliance for user data", - slugId: "auth-redesign-2024", - icon: "🔐", - color: "#3b82f6", - status: { - id: "status-started", - name: "In Progress", - color: "#f59e0b", - }, - creator: { - name: "john.admin", - displayName: "John Admin", - }, - lead: { - name: "jane.lead", - displayName: "Jane Lead", - }, - priority: 2, - health: "onTrack", - startDate: "2024-01-15", - targetDate: "2024-04-30", - startedAt: "2024-01-16T09:00:00Z", - completedAt: null, - canceledAt: null, - updatedAt: "2024-01-25T14:30:00Z", - createdAt: "2024-01-10T10:00:00Z", - url: "https://linear.app/acme/project/auth-redesign-2024", - teams: { - nodes: [ - { - id: "team-1", - key: "BACKEND", - name: "Backend Team", - }, - { - id: "team-2", - key: "SECURITY", - name: "Security Team", - }, - ], - }, - issues: { - nodes: [ - { - id: "issue-1", - identifier: "AUTH-101", - title: "Implement OAuth 2.0 flow", - state: { - name: "In Progress", - type: "started", - }, - }, - { - id: "issue-2", - identifier: "AUTH-102", - title: "Add MFA support", - state: { - name: "To Do", - type: "unstarted", - }, - }, - { - id: "issue-3", - identifier: "AUTH-103", - title: "Design new login UI", - state: { - name: "Done", - type: "completed", - }, - }, - { - id: "issue-4", - identifier: "AUTH-104", - title: "Security audit of current system", - state: { - name: "Canceled", - type: "canceled", - }, - }, - ], - }, - lastUpdate: { - id: "update-1", - body: - "Great progress this week! The OAuth implementation is nearly complete and we're on track for our Q1 delivery. The team has been collaborating well across backend and security concerns.\n\n**This week's highlights:**\n- OAuth 2.0 flow implementation 80% complete\n- MFA design reviews completed\n- Security penetration testing scheduled\n\n**Next week:**\n- Complete OAuth testing\n- Begin MFA implementation\n- Finalize UI designs", - health: "onTrack", - createdAt: "2024-01-22T16:00:00Z", - user: { - name: "jane.lead", - displayName: "Jane Lead", - }, - }, - }, - }, - }, + variables: { id: PROJECT_UUID, first: 250 }, + response: { data: { project: richProject() } }, }, - ]) - - try { - await server.start() - Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) - Deno.env.set("LINEAR_API_KEY", "Bearer test-token") - + ], async () => { await viewCommand.parse() - } finally { - await server.stop() - Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") - Deno.env.delete("LINEAR_API_KEY") - } + }) }, }) -// Test with minimal project (no optional fields) +// A project with nothing optional set must not emit empty headings. await snapshotTest({ name: "Project View Command - Minimal Project", meta: import.meta, colors: false, - args: ["minimal-project"], + args: [MINIMAL_UUID], denoArgs, async fn() { - const server = new MockLinearServer([ + await withMockServer([ { queryName: "GetProjectDetails", - variables: { id: "minimal-project" }, - response: { - data: { - project: { - id: "minimal-project", - name: "Simple Project", - description: "", - slugId: "simple", - icon: null, - color: "#64748b", - status: { - id: "status-backlog", - name: "Backlog", - color: "#94a3b8", - }, - creator: null, - lead: null, - priority: 0, - health: null, - startDate: null, - targetDate: null, - startedAt: null, - completedAt: null, - canceledAt: null, - updatedAt: "2024-01-20T12:00:00Z", - createdAt: "2024-01-20T12:00:00Z", - url: "https://linear.app/acme/project/simple", - teams: { - nodes: [], - }, - issues: { - nodes: [], - }, - lastUpdate: null, - }, - }, - }, + variables: { id: MINIMAL_UUID, first: 250 }, + response: { data: { project: minimalProject() } }, }, - ]) - - try { - await server.start() - Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) - Deno.env.set("LINEAR_API_KEY", "Bearer test-token") - + ], async () => { await viewCommand.parse() - } finally { - await server.stop() - Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") - Deno.env.delete("LINEAR_API_KEY") - } + }) }, }) -// JSON is the GraphQL project object as selected: numeric priority (no label), -// nested status/lead/teams/issues/lastUpdate, and the issues connection's -// pageInfo so callers can tell when Linear's default page was partial. +// JSON keeps the GraphQL field names and the `{ nodes, pageInfo }` connection +// shape rather than flattening the relationships into CLI-specific arrays. await snapshotTest({ name: "Project View Command - JSON Output", meta: import.meta, colors: false, - args: ["project-123", "--json"], + args: [PROJECT_UUID, "--json"], denoArgs, async fn() { - const server = new MockLinearServer([ + await withMockServer([ { queryName: "GetProjectDetails", - variables: { id: "project-123" }, + variables: { id: PROJECT_UUID, first: 250 }, response: { data: { - project: { - id: "project-123", - name: "Authentication System Redesign", - description: "Overhaul auth.", - slugId: "auth-redesign-2024", - icon: "🔐", - color: "#3b82f6", - status: { id: "status-1", name: "In Progress", color: "#f59e0b" }, - creator: { name: "john.admin", displayName: "John Admin" }, - lead: { name: "jane.lead", displayName: "Jane Lead" }, - priority: 2, - health: "onTrack", - startDate: "2024-01-15", - targetDate: "2024-03-31", - startedAt: "2024-01-15T09:00:00Z", - completedAt: null, - canceledAt: null, - updatedAt: "2024-01-25T14:30:00Z", - createdAt: "2024-01-10T10:00:00Z", - url: "https://linear.app/acme/project/auth-redesign-2024", - teams: { - nodes: [{ id: "team-1", key: "BACKEND", name: "Backend Team" }], - }, - issues: { - nodes: [ - { - id: "issue-1", - identifier: "AUTH-101", - title: "Implement OAuth 2.0 flow", - state: { name: "In Progress", type: "started" }, - }, - ], - pageInfo: { hasNextPage: true, endCursor: "issues-cursor-1" }, - }, - lastUpdate: { - id: "update-1", - body: "On track.", - health: "onTrack", - createdAt: "2024-01-25T14:30:00Z", - user: { name: "jane.lead", displayName: "Jane Lead" }, + project: richProject({ + relations: emptyConnection, + inverseRelations: emptyConnection, + attachments: emptyConnection, + }), + }, + }, + }, + ], async () => { + await viewCommand.parse() + }) + }, +}) + +Deno.test("project view says when a connection was cut off", async () => { + await withMockServer([ + { + queryName: "GetProjectDetails", + variables: { id: PROJECT_UUID, first: 250 }, + response: { + data: { + project: richProject({ + externalLinks: connection([ + { + id: "e1", + label: "Design doc", + url: "https://example.com/design", + sortOrder: 4, }, + ], true), + }), + }, + }, + }, + ], async () => { + const originalLog = console.log + const output: string[] = [] + console.log = (...args: unknown[]) => { + output.push(args.map(String).join(" ")) + } + try { + await viewCommand.parse([PROJECT_UUID]) + } finally { + console.log = originalLog + } + + const rendered = output.join("\n") + const resources = rendered.slice( + rendered.indexOf("## Resources"), + rendered.indexOf("## Documents"), + ) + // The section that ran out says so; the ones that did not stay quiet. + assertStringIncludes(resources, "_…and more (showing the first 250)._") + assertEquals(rendered.split("…and more").length - 1, 1) + }) +}) + +Deno.test("project view resolves a project name before fetching details", async () => { + await withMockServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Simple Project" }, + response: { data: { projects: { nodes: [{ id: MINIMAL_UUID }] } } }, + }, + { + queryName: "GetProjectDetails", + variables: { id: MINIMAL_UUID, first: 250 }, + response: { data: { project: minimalProject() } }, + }, + ], async () => { + const originalLog = console.log + const output: string[] = [] + console.log = (...args: unknown[]) => { + output.push(args.map(String).join(" ")) + } + try { + await viewCommand.parse(["Simple Project"]) + } finally { + console.log = originalLog + } + + assertStringIncludes(output.join("\n"), "# Simple Project") + }) +}) + +Deno.test("project view walks every page of the issues connection", async () => { + await withMockServer([ + { + queryName: "GetProjectDetails", + variables: { id: PROJECT_UUID, first: 250 }, + response: { + data: { + project: richProject({ + issues: { + nodes: [ + { + id: "issue-1", + identifier: "AUTH-101", + title: "Page one", + state: { id: "s1", name: "Done", type: "completed" }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: "issues-page-1" }, + }, + }), + }, + }, + }, + { + queryName: "GetProjectIssuesPage", + variables: { id: PROJECT_UUID, first: 250, after: "issues-page-1" }, + response: { + data: { + project: { + id: PROJECT_UUID, + issues: { + nodes: [ + { + id: "issue-2", + identifier: "AUTH-102", + title: "Page two", + state: { id: "s2", name: "Done", type: "completed" }, + }, + { + id: "issue-3", + identifier: "AUTH-103", + title: "Page two again", + state: { id: "s3", name: "To Do", type: "unstarted" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "issues-page-2" }, }, }, }, }, - ]) + }, + ], async () => { + const originalLog = console.log + const output: string[] = [] + console.log = (...args: unknown[]) => { + output.push(args.map(String).join(" ")) + } + try { + await viewCommand.parse([PROJECT_UUID]) + } finally { + console.log = originalLog + } + + // Counting only the first page would report "1 total · 1 completed". + assertStringIncludes( + output.join("\n"), + "3 total · 1 to do · 2 completed", + ) + }) +}) + +Deno.test("project view refuses an issue cursor that never advances", async () => { + await withMockServer([ + { + queryName: "GetProjectDetails", + variables: { id: PROJECT_UUID, first: 250 }, + response: { + data: { + project: richProject({ + issues: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: "stuck" }, + }, + }), + }, + }, + }, + { + queryName: "GetProjectIssuesPage", + variables: { id: PROJECT_UUID, first: 250, after: "stuck" }, + response: { + data: { + project: { + id: PROJECT_UUID, + issues: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: "stuck" }, + }, + }, + }, + }, + }, + ], async () => { + const originalError = console.error + const originalExit = Deno.exit + const errors: string[] = [] + let exitCode: number | undefined + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")) + } + // Deno.exit never returns, but the command keeps going after handleError + // calls it, so the stub has to satisfy the `never` return type. + Deno.exit = ((code?: number) => { + exitCode = code + throw new Error("exit") + }) as typeof Deno.exit try { - await server.start() - Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) - Deno.env.set("LINEAR_API_KEY", "Bearer test-token") - await viewCommand.parse() + await viewCommand.parse([PROJECT_UUID]) + } catch (error) { + if (!(error instanceof Error) || error.message !== "exit") throw error } finally { - await server.stop() - Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") - Deno.env.delete("LINEAR_API_KEY") + console.error = originalError + Deno.exit = originalExit } - }, + + assertEquals(exitCode, 1) + assertStringIncludes(errors.join("\n"), "same issue cursor twice") + }) +}) + +Deno.test("project view rejects a non-numeric resource sort order", async () => { + await withMockServer([ + { + queryName: "GetProjectDetails", + variables: { id: PROJECT_UUID, first: 250 }, + response: { + data: { + project: richProject({ + externalLinks: connection([ + { + id: "e1", + label: "Design doc", + url: "https://example.com/design", + sortOrder: null, + }, + { + id: "e2", + label: "Figma board", + url: "https://figma.com/file/abc", + sortOrder: 2, + }, + ]), + }), + }, + }, + }, + ], async () => { + const originalError = console.error + 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) => { + exitCode = code + throw new Error("exit") + }) as typeof Deno.exit + + try { + await viewCommand.parse([PROJECT_UUID]) + } catch (error) { + if (!(error instanceof Error) || error.message !== "exit") throw error + } finally { + console.error = originalError + Deno.exit = originalExit + } + + assertEquals(exitCode, 1) + assertStringIncludes(errors.join("\n"), "non-numeric resource sortOrder") + }) +}) + +Deno.test("project view marks an inline list that was cut off", async () => { + await withMockServer([ + { + queryName: "GetProjectDetails", + variables: { id: PROJECT_UUID, first: 250 }, + response: { + data: { + project: richProject({ + labels: connection( + [{ id: "l1", name: "security", color: "#000" }], + true, + ), + }), + }, + }, + }, + ], async () => { + const originalLog = console.log + const output: string[] = [] + console.log = (...args: unknown[]) => { + output.push(args.map(String).join(" ")) + } + try { + await viewCommand.parse([PROJECT_UUID]) + } finally { + console.log = originalLog + } + + // The meta line has no room for the block note the sections use, so a + // partial list ends in an ellipsis rather than reading as complete. + assertStringIncludes(output.join("\n"), "**Labels:** security, …") + }) }) diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index 8b7f7f83..4a3c2d4c 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -247,6 +247,35 @@ Deno.test("resolveProjectId - falls back to slug ID when name does not match", a } }) +Deno.test("resolveProjectId - rejects a name shared by several projects", async () => { + // Linear does not require project names to be unique, so a name that matches + // more than one project names no project in particular. + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Platform" }, + response: { + data: { + projects: { + nodes: [{ id: "proj-one-uuid" }, { id: "proj-two-uuid" }], + }, + }, + }, + }, + ]) + try { + const error = await assertRejects( + () => resolveProjectId("Platform"), + ValidationError, + 'Project "Platform" is ambiguous', + ) + assertStringIncludes(error.message, "proj-one-uuid") + assertStringIncludes(error.message, "proj-two-uuid") + } finally { + await cleanup() + } +}) + Deno.test("resolveProjectId - throws NotFoundError when nothing matches", async () => { const { cleanup } = await setupMockLinearServer([ { From 62618c5de2c951aa10c75d0907ec1177ae1f500b Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 17 Sep 2026 16:51:26 -0700 Subject: [PATCH 2/3] Let project view pick a project when given none Every other way of naming a project requires already knowing its UUID, slug or exact name, which for a read command is backwards: the usual reason to run project view is to go and look at something you have not memorised the identifier of. Running it bare was simply an error. Make the argument optional and, when it is missing, offer a searchable list built from the same scope project list uses -- the configured team, or the whole workspace when no team is set. The list is fetched in full rather than capped, because the prompt filters client-side and anything left unfetched would be undiscoverable. Labels carry the status, team keys and slug beside the name: project names are not unique, and the extra terms give the search something to match on. The value behind each row is always the UUID, so what is displayed can never change which project is opened. Prompting is only ever right when someone is there to answer, so every other case errors before any network call rather than hanging: piped, redirected, in CI, or with --json, where prompt output would also land in the middle of the JSON. An empty scope errors too instead of opening a prompt with nothing in it. --- CHANGELOG.md | 1 + README.md | 1 + docs/usage.md | 3 + skills/linear-cli/references/project.md | 15 +- src/commands/project/project-view.ts | 208 +++++++++++++++++- .../__snapshots__/project-view.test.ts.snap | 2 +- test/commands/project/project-view.test.ts | 177 ++++++++++++++- 7 files changed, 391 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2b4697..9baae43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### 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 ` 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 ` 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 diff --git a/README.md b/README.md index 0d372ee6..ca3aeef8 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ linear user list --json # machine-readable output ```bash linear project list # list projects +linear project view # pick from a searchable list of projects linear project view # overview, milestones, resources, documents, related projects linear project view "Mobile launch" # a UUID, slug ID, or exact name all work linear project view --json # project details as JSON diff --git a/docs/usage.md b/docs/usage.md index 2a9e6c55..8d2fdfc5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -372,6 +372,9 @@ 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 diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index b841d0ff..2c3cca56 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -19,7 +19,7 @@ Options: Commands: list - List projects - view, v - View project details + view, v [projectId] - View project details create - Create a new Linear project update - Update a Linear project delete - Delete (trash) a Linear project @@ -232,7 +232,7 @@ Options: > View project details ``` -Usage: linear project view +Usage: linear project view [projectId] Description: @@ -240,9 +240,10 @@ Description: Options: - -h, --help - Show this help. - --workspace - 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 - 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 ``` diff --git a/src/commands/project/project-view.ts b/src/commands/project/project-view.ts index adeb84d3..f3bd9818 100644 --- a/src/commands/project/project-view.ts +++ b/src/commands/project/project-view.ts @@ -2,14 +2,17 @@ import { Command } from "@cliffy/command" import { renderMarkdown } from "@littletof/charmd" import type { Extension } from "@littletof/charmd" import { gql } from "../../__codegen__/gql.ts" -import type { GetProjectDetailsQuery } from "../../__codegen__/graphql.ts" +import type { + GetProjectDetailsQuery, + GetProjectsForPickerQuery, +} from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime, getProjectPriorityLabel, } from "../../utils/display.ts" import { openProjectPage } from "../../utils/actions.ts" -import { resolveProjectId } from "../../utils/linear.ts" +import { getTeamKey, resolveProjectId } from "../../utils/linear.ts" import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" import { shouldEnableHyperlinks, @@ -17,7 +20,12 @@ import { } from "../../utils/hyperlink.ts" import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts" import { getOption } from "../../config.ts" -import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" +import { + CliError, + handleError, + NotFoundError, + ValidationError, +} from "../../utils/errors.ts" /** * Linear caps connection pages at 250. Every connection below is requested at @@ -766,11 +774,192 @@ export function formatProjectAsMarkdown(project: ProjectDetails): string { return markdown } +const PICKER_PAGE_SIZE = 100 + +const GetProjectsForPicker = gql(` + query GetProjectsForPicker($filter: ProjectFilter, $first: Int!, $after: String) { + projects(filter: $filter, first: $first, after: $after) { + nodes { + id + name + slugId + status { + name + } + teams(first: 10) { + nodes { + key + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +type PickerProject = GetProjectsForPickerQuery["projects"]["nodes"][number] + +export interface ProjectPickerOption { + name: string + value: string +} + +/** + * Label a project for the picker. + * + * Project names are not unique and a project can span several teams, so the + * status, team keys and slug all stay in the label: they disambiguate two + * same-named projects and they give the prompt's type-to-filter search more to + * match against. The value is always the UUID, so what the user sees can never + * change which project is opened. + */ +export function buildProjectPickerOptions( + projects: readonly PickerProject[], +): ProjectPickerOption[] { + return [...projects] + .sort((a, b) => { + const byName = a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + if (byName !== 0) return byName + const bySlug = a.slugId.localeCompare(b.slugId) + if (bySlug !== 0) return bySlug + return a.id.localeCompare(b.id) + }) + .map((project) => { + const teams = project.teams.nodes.map((team) => team.key).join(", ") + const parts = [project.name, project.status.name] + if (teams !== "") parts.push(teams) + parts.push(project.slugId) + return { name: parts.join(" · "), value: project.id } + }) +} + +/** + * Fetch every project the picker can offer. + * + * The prompt filters client-side, so anything left unfetched is simply + * undiscoverable — hence every page rather than a cap. Scope matches + * `project list`: the configured team when there is one, otherwise everything + * accessible. + */ +async function fetchProjectsForPicker( + teamKey: string | undefined, +): Promise { + const client = getGraphQLClient() + const filter = teamKey != null + ? { accessibleTeams: { some: { key: { eq: teamKey } } } } + : undefined + + const projects: PickerProject[] = [] + // Annotated because `after` is assigned from a value derived from the request + // it is passed to, which TypeScript cannot infer without help. + let after: string | undefined = undefined + + while (true) { + const data: GetProjectsForPickerQuery = await client.request( + GetProjectsForPicker, + { + filter, + first: PICKER_PAGE_SIZE, + after, + }, + ) + projects.push(...data.projects.nodes) + + const pageInfo = data.projects.pageInfo + if (!pageInfo.hasNextPage) break + if (pageInfo.endCursor == null || pageInfo.endCursor === after) { + throw new CliError( + "Linear reported more projects but returned no new cursor to fetch them.", + { suggestion: "Retry, or pass a project explicitly." }, + ) + } + after = pageInfo.endCursor + } + + return projects +} + +/** Injected in tests so the selection flow can be exercised without a terminal. */ +export type ProjectPrompt = ( + options: ProjectPickerOption[], +) => Promise + +async function promptForProject( + options: ProjectPickerOption[], +): Promise { + const { Select } = await import("@cliffy/prompt") + return await Select.prompt({ + message: "Select a project", + options, + search: true, + searchLabel: "Search projects", + }) +} + +/** + * Resolve the project to act on when no argument was given. + * + * Prompting is only ever right when a person is actually there to answer, so + * every other case errors up front — before any network call — rather than + * hanging a pipeline on a prompt nobody can see or interleaving prompt output + * with JSON on stdout. + */ +export async function selectProject( + options: { json: boolean; prompt?: ProjectPrompt }, +): Promise { + if (options.json) { + throw new ValidationError( + "A project is required with --json", + { + suggestion: + "Pass a project UUID, slug ID, or exact name, or drop --json to pick one from a list.", + }, + ) + } + + // Some CI runners allocate a pseudo-terminal, which makes both isTerminal() + // checks pass even though nobody is there to answer the prompt. CI is + // therefore treated as non-interactive regardless of what the tty looks like. + const inCi = Deno.env.get("CI") != null && Deno.env.get("CI") !== "false" && + Deno.env.get("CI") !== "" + const interactive = options.prompt != null || + (!inCi && Deno.stdin.isTerminal() && Deno.stdout.isTerminal()) + if (!interactive) { + throw new ValidationError( + "No project specified", + { + suggestion: + "Pass a project UUID, slug ID, or exact name. Running `linear project view` with no argument picks from a list, but only on a terminal.", + }, + ) + } + + const teamKey = getTeamKey() + const projects = await fetchProjectsForPicker(teamKey) + if (projects.length === 0) { + throw new NotFoundError( + "Project", + teamKey != null ? `team ${teamKey}` : "this workspace", + { + suggestion: teamKey != null + ? `No projects are accessible to team ${teamKey}. Check \`linear project list --all-teams\`, or create one with \`linear project create\`.` + : "Create one with `linear project create`.", + }, + ) + } + + const prompt = options.prompt ?? promptForProject + return await prompt(buildProjectPickerOptions(projects)) +} + export const viewCommand = new Command() .name("view") .description("View project details") .alias("v") - .arguments("") + .arguments("[projectId:string]") .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") .option("-j, --json", "Output as JSON") @@ -786,8 +975,13 @@ export const viewCommand = new Command() try { // Resolving up front means a project name works everywhere the command // accepts an identifier, rather than only on the paths that happen to hit - // the GraphQL `project(id:)` field. - const resolvedId = await resolveProjectId(projectId) + // the GraphQL `project(id:)` field. With no argument at all, the picker + // already hands back a UUID. + const reference = projectId ?? + await selectProject({ json: json === true }) + const resolvedId = projectId == null + ? reference + : await resolveProjectId(reference) if (web || app) { await openProjectPage(resolvedId, { app, web: !app }) @@ -795,7 +989,7 @@ export const viewCommand = new Command() } spinner?.start() - const project = await fetchProjectDetails(resolvedId, projectId) + const project = await fetchProjectDetails(resolvedId, reference) spinner?.stop() if (json) { diff --git a/test/commands/project/__snapshots__/project-view.test.ts.snap b/test/commands/project/__snapshots__/project-view.test.ts.snap index f7f948bd..af270549 100644 --- a/test/commands/project/__snapshots__/project-view.test.ts.snap +++ b/test/commands/project/__snapshots__/project-view.test.ts.snap @@ -3,7 +3,7 @@ export const snapshot = {}; snapshot[`Project View Command - Help Text 1`] = ` stdout: " -Usage: view +Usage: view [projectId] Description: diff --git a/test/commands/project/project-view.test.ts b/test/commands/project/project-view.test.ts index cd6922cd..0450b73d 100644 --- a/test/commands/project/project-view.test.ts +++ b/test/commands/project/project-view.test.ts @@ -1,6 +1,12 @@ import { snapshotTest } from "@cliffy/testing" import { assertEquals, assertStringIncludes } from "@std/assert" -import { viewCommand } from "../../../src/commands/project/project-view.ts" +import { + buildProjectPickerOptions, + type ProjectPickerOption, + selectProject, + viewCommand, +} from "../../../src/commands/project/project-view.ts" +import { ValidationError } from "../../../src/utils/errors.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" // Common Deno args for permissions @@ -647,3 +653,172 @@ Deno.test("project view marks an inline list that was cut off", async () => { assertStringIncludes(output.join("\n"), "**Labels:** security, …") }) }) + +function pickerProject( + id: string, + name: string, + slugId: string, + statusName = "Backlog", + teamKeys: string[] = ["ENG"], +) { + return { + id, + name, + slugId, + status: { name: statusName }, + teams: { nodes: teamKeys.map((key) => ({ key })) }, + } +} + +Deno.test("project picker labels and orders projects for searching", () => { + const options = buildProjectPickerOptions([ + pickerProject("id-c", "zeta", "slug-c", "Completed", ["OPS"]), + pickerProject("id-b", "Alpha", "slug-b2", "In Progress", ["ENG", "OPS"]), + // Same name as the one above: the slug is what separates them. + pickerProject("id-a", "Alpha", "slug-a1", "Backlog", ["ENG"]), + ]) + + assertEquals(options.map((option) => option.value), [ + "id-a", + "id-b", + "id-c", + ]) + assertEquals(options[0].name, "Alpha · Backlog · ENG · slug-a1") + assertEquals( + options[1].name, + "Alpha · In Progress · ENG, OPS · slug-b2", + ) + // Ordering is case-insensitive, so "zeta" sorts after "Alpha". + assertEquals(options[2].name, "zeta · Completed · OPS · slug-c") +}) + +Deno.test("project picker refuses to prompt when output is machine-readable", async () => { + let prompted = false + const error = await selectProject({ + json: true, + prompt: () => { + prompted = true + return Promise.resolve("never") + }, + }).catch((error: unknown) => error) + + assertEquals(prompted, false) + assertEquals(error instanceof ValidationError, true) + assertStringIncludes(String(error), "A project is required with --json") +}) + +Deno.test("project picker gathers every page before prompting", async () => { + // MockLinearServer matches a mock when every variable it names matches, so + // the cursor-bearing page has to come first or the first-page mock would also + // answer the second request. + await withMockServer([ + { + queryName: "GetProjectsForPicker", + variables: { first: 100, after: "page-1" }, + response: { + data: { + projects: { + nodes: [pickerProject("id-1", "Alpha", "slug-1")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + { + queryName: "GetProjectsForPicker", + variables: { first: 100 }, + response: { + data: { + projects: { + nodes: [pickerProject("id-2", "Beta", "slug-2")], + pageInfo: { hasNextPage: true, endCursor: "page-1" }, + }, + }, + }, + }, + ], async () => { + let offered: ProjectPickerOption[] = [] + const chosen = await selectProject({ + json: false, + prompt: (options) => { + offered = options + return Promise.resolve(options[0].value) + }, + }) + + // The second page must be in the list, or it would be unreachable: the + // prompt only filters what it was handed. + assertEquals(offered.map((option) => option.value), ["id-1", "id-2"]) + assertEquals(chosen, "id-1") + }) +}) + +Deno.test("project picker errors instead of opening an empty prompt", async () => { + await withMockServer([ + { + queryName: "GetProjectsForPicker", + variables: { first: 100 }, + response: { + data: { + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], async () => { + let prompted = false + const error = await selectProject({ + json: false, + prompt: () => { + prompted = true + return Promise.resolve("never") + }, + }).catch((error: unknown) => error) + + assertEquals(prompted, false) + assertStringIncludes(String(error), "Project not found") + }) +}) + +Deno.test("project picker refuses a project cursor that never advances", async () => { + await withMockServer([ + { + queryName: "GetProjectsForPicker", + variables: { first: 100 }, + response: { + data: { + projects: { + nodes: [pickerProject("id-1", "Alpha", "slug-1")], + pageInfo: { hasNextPage: true, endCursor: null }, + }, + }, + }, + }, + ], async () => { + const error = await selectProject({ + json: false, + prompt: () => Promise.resolve("never"), + }).catch((error: unknown) => error) + + assertStringIncludes(String(error), "no new cursor") + }) +}) + +Deno.test("project picker treats CI as non-interactive even with a tty", async () => { + const originalCi = Deno.env.get("CI") + Deno.env.set("CI", "true") + try { + const error = await selectProject({ json: false }).catch(( + error: unknown, + ) => error) + assertStringIncludes(String(error), "No project specified") + } finally { + if (originalCi == null) { + Deno.env.delete("CI") + } else { + Deno.env.set("CI", originalCi) + } + } +}) From cbdd57234f2f3665f9ba2c0ec15d85dd7ccc644b Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 17 Sep 2026 16:58:00 -0700 Subject: [PATCH 3/3] Order project list the way Linear's project list does project list sorted by a hardcoded status ranking that put in-progress work first, then fell back to the project name. Linear does neither. It orders by where the status sits in the project flow -- backlog, planned, in progress, paused, completed, canceled -- then by the position the workspace has given that status within its category, then by the manual order projects are dragged into. The CLI ignored the workspace's own status positions and the manual order entirely, so two projects a user had deliberately arranged came back alphabetised. Select sortOrder and status.position and compare on those, with name and id left to break exact ties so the listing is stable across runs. The status ranking is an exhaustive switch rather than a lookup table reached through a cast, so a status type added to the schema fails the type check here, where someone has to decide where in the flow it belongs, instead of silently sorting to the end. A sort key that arrives non-numeric is reported rather than allowed to return NaN from the comparator and scramble the listing. This is reconstructed from the schema rather than observed in the app: position is documented as ordering statuses "within its type group" and sortOrder as the manual order used in list views. No browser session was available to check it against linear.app directly. The ordering is not sorted server-side. The `sort:` argument on projects() is marked [INTERNAL], and since every page is fetched before sorting anyway it would add a dependency on an unsupported argument for no benefit. The two command-level ordering snapshots remain ignored for a pre-existing mock server problem that predates this change and breaks the rest of the file when they are enabled, so the ordering rule is exercised directly instead. --- CHANGELOG.md | 1 + src/commands/project/project-list.ts | 155 +++++++++++----- .../__snapshots__/project-list.test.ts.snap | 74 ++++---- test/commands/project/project-list.test.ts | 174 +++++++++++++++++- 4 files changed, 321 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9baae43f..d1faf871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +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 - `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 0f215d12..33e7f5b8 100644 --- a/src/commands/project/project-list.ts +++ b/src/commands/project/project-list.ts @@ -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) { @@ -24,11 +28,13 @@ const GetProjects = gql(` slugId icon color + sortOrder status { id name color type + position } lead { name @@ -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") @@ -183,30 +283,7 @@ export const listCommand = new Command() return } - // Sort projects logically by status then by relevant date - const statusOrder: Record = { - "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( @@ -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 @@ -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(",") || "-" diff --git a/test/commands/project/__snapshots__/project-list.test.ts.snap b/test/commands/project/__snapshots__/project-list.test.ts.snap index 6a5527b4..a1241578 100644 --- a/test/commands/project/__snapshots__/project-list.test.ts.snap +++ b/test/commands/project/__snapshots__/project-list.test.ts.snap @@ -55,13 +55,15 @@ stdout: "name": "JSON Test Project", "description": "A project for JSON output", "slugId": "json-proj", + "sortOrder": 400, "icon": null, "color": "#3b82f6", "status": { "id": "status-1", "name": "In Progress", "color": "#f59e0b", - "type": "started" + "type": "started", + "position": 2 }, "lead": { "name": "test.user", @@ -101,50 +103,20 @@ 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", - "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", "description": "Second page project", "slugId": "beta-proj", + "sortOrder": 1000, "icon": null, "color": "#10b981", "status": { "id": "status-2", "name": "Planned", "color": "#6366f1", - "type": "planned" + "type": "planned", + "position": 1 }, "lead": { "name": "pat.planner", @@ -168,6 +140,40 @@ 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 2d606927..422aee6f 100644 --- a/test/commands/project/project-list.test.ts +++ b/test/commands/project/project-list.test.ts @@ -1,6 +1,12 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" import { snapshotTest } from "../../utils/snapshot_with_fake_time.ts" -import { listCommand } from "../../../src/commands/project/project-list.ts" +import { + compareProjectsForDisplay, + 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" @@ -39,6 +45,7 @@ await snapshotTest({ name: "Authentication System", description: "Core authentication and authorization system", slugId: "auth-sys", + sortOrder: 100, icon: "🔐", color: "#3b82f6", status: { @@ -46,6 +53,7 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", + position: 2, }, lead: { name: "jane.smith", @@ -75,6 +83,7 @@ await snapshotTest({ description: "Complete redesign of the mobile application interface", slugId: "mobile-ui", + sortOrder: 200, icon: "📱", color: "#ef4444", status: { @@ -82,6 +91,7 @@ await snapshotTest({ name: "Planned", color: "#6366f1", type: "planned", + position: 1, }, lead: { name: "alex.designer", @@ -110,6 +120,7 @@ await snapshotTest({ name: "API Documentation", description: "Comprehensive API documentation and examples", slugId: "api-docs", + sortOrder: 300, icon: null, color: "#10b981", status: { @@ -117,6 +128,7 @@ await snapshotTest({ name: "Completed", color: "#059669", type: "completed", + position: 4, }, lead: null, priority: 4, @@ -261,6 +273,7 @@ await cliffySnapshotTest({ name: "JSON Test Project", description: "A project for JSON output", slugId: "json-proj", + sortOrder: 400, icon: null, color: "#3b82f6", status: { @@ -268,6 +281,7 @@ await cliffySnapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", + position: 2, }, lead: { name: "test.user", @@ -337,6 +351,7 @@ await snapshotTest({ name: "Alpha Project", description: "First project on page 1", slugId: "alpha-proj", + sortOrder: 500, icon: "🅰️", color: "#3b82f6", status: { @@ -344,6 +359,7 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", + position: 2, }, lead: { name: "alice", @@ -369,6 +385,7 @@ await snapshotTest({ name: "Beta Project", description: "Second project on page 1", slugId: "beta-proj", + sortOrder: 600, icon: "🅱️", color: "#ef4444", status: { @@ -376,6 +393,7 @@ await snapshotTest({ name: "Planned", color: "#6366f1", type: "planned", + position: 1, }, lead: { name: "bob", @@ -422,6 +440,7 @@ await snapshotTest({ name: "Gamma Project", description: "First project on page 2", slugId: "gamma-proj", + sortOrder: 700, icon: "🔤", color: "#10b981", status: { @@ -429,6 +448,7 @@ await snapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", + position: 2, }, lead: { name: "carol", @@ -454,6 +474,7 @@ await snapshotTest({ name: "Delta Project", description: "Second project on page 2", slugId: "delta-proj", + sortOrder: 800, icon: "🔺", color: "#f59e0b", status: { @@ -461,6 +482,7 @@ await snapshotTest({ name: "Completed", color: "#059669", type: "completed", + position: 4, }, lead: null, priority: 4, @@ -522,6 +544,7 @@ await cliffySnapshotTest({ name: "Alpha Project", description: "First page project", slugId: "alpha-proj", + sortOrder: 900, icon: null, color: "#3b82f6", status: { @@ -529,6 +552,7 @@ await cliffySnapshotTest({ name: "In Progress", color: "#f59e0b", type: "started", + position: 2, }, lead: null, priority: 2, @@ -566,6 +590,7 @@ await cliffySnapshotTest({ name: "Beta Project", description: "Second page project", slugId: "beta-proj", + sortOrder: 1000, icon: null, color: "#10b981", status: { @@ -573,6 +598,7 @@ await cliffySnapshotTest({ name: "Planned", color: "#6366f1", type: "planned", + position: 1, }, lead: { name: "pat.planner", @@ -664,3 +690,149 @@ 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", () => { + const project = ( + id: string, + name: string, + type: ProjectStatusType, + position: number, + sortOrder: number, + ): ProjectDisplayOrderKey => ({ + id, + name, + status: { type, position }, + sortOrder, + }) + + // Deliberately scrambled, and covering every status type. + 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), + ] + + 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", + ]) +}) + +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, + }) + + const ordered = [ + tied("id-z", "Same name"), + tied("id-a", "Same name"), + tied("id-m", "Another name"), + ].sort(compareProjectsForDisplay) + + 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" }] }, + }) + + const server = new MockLinearServer([ + { + queryName: "GetProjects", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + projects: { + nodes: [node("broken", "Broken", null), node("fine", "Fine", 2)], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ]) + + const originalError = console.error + 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) => { + exitCode = code + throw new Error("exit") + }) as typeof Deno.exit + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse(["--all-teams"]) + } catch (error) { + if (!(error instanceof Error) || error.message !== "exit") throw error + } finally { + console.error = originalError + Deno.exit = originalExit + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + + assertEquals(exitCode, 1) + assertStringIncludes(errors.join("\n"), "non-numeric sortOrder") +})