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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Added

- every command that takes an issue, project, document, initiative, or team now also takes the URL you copied out of Linear — `linear issue view https://linear.app/acme/issue/ENG-123/some-title`, `linear project view <project url>`, `--project`, `--parent`, `--team` and the rest, since they all resolve through the same lookups. `issue view <url>` did not work at all before; project and document URLs happened to work through an undocumented server-side behavior in Linear's API, which the CLI no longer relies on. A URL pointing at the wrong kind of thing now says so ("that is an issue URL, not a project URL") instead of reporting the whole URL as a missing name, as does one from another workspace, a page that names nothing (`/settings`), or a cycle URL, whose shape Linear does not publish. Commands whose identifiers have no URL at all — milestones, labels, templates, releases — say that plainly. A comment link carries only the first eight characters of the comment's ID, so it names its issue but cannot be used as a comment ID. `issue link <url>` is unchanged: a lone URL there is still the thing being linked
- `project view` now shows what Linear's project page shows: the long-form overview body (`content`), milestones with their status and progress, resources (`externalLinks`), documents, attachments, related projects with their dependency direction, labels, members, initiatives, and Linear's own progress percentage. Only `description` — the 255-character summary — was rendered before, so a project whose body was written with `project create --content-file` displayed nothing of it. A project reference can now be a UUID, slug ID, or exact name everywhere, including with `--web`/`--app`, and long output pages like `issue view` does (`--no-pager` to disable). `--json` keeps the GraphQL field names and the `{ nodes, pageInfo }` shape of every connection
- `project view` with no argument opens a searchable list of projects to pick from, scoped like `project list` — the configured team, or the whole workspace when no team is set. It only prompts when stdin and stdout are both terminals; piped, redirected, in CI, or with `--json` it says a project is required instead of hanging on a prompt nobody can answer
- `issue archive <id>` archives an issue through Linear's `issueArchive` mutation, distinct from `issue delete`, which trashes it. It resolves identifiers like the other issue commands, prompts with the identifier and title unless `--confirm`/`-y` is passed, reports an already-archived issue instead of silently succeeding, and takes `--bulk`, `--bulk-file`, and `--bulk-stdin` like `issue delete` ([#285](https://github.com/schpet/linear-cli/pull/285); thanks @martin-piliar for the command and the report in [#284](https://github.com/schpet/linear-cli/issues/284))
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ linear user list --json # machine-readable output

```bash
linear project list # list projects
linear project view https://linear.app/acme/project/mobile-launch-272f50ef9250 # paste a URL from Linear
linear project view # pick from a searchable list of projects
linear project view <projectId> # overview, milestones, resources, documents, related projects
linear project view "Mobile launch" # a UUID, slug ID, or exact name all work
Expand Down
12 changes: 12 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@ linear project update PROJECT-ID --initiative "Q4 Bets" --initiative "Platform"
linear project list
```

#### referring to things by URL

Anywhere the CLI takes an issue, project, document, initiative, or team, you can paste the URL from Linear instead of its ID, slug, or name.

```bash
linear issue view https://linear.app/acme/issue/ENG-123/some-title
linear project view https://linear.app/acme/project/mobile-launch-272f50ef9250
linear issue query --project https://linear.app/acme/project/mobile-launch-272f50ef9250
```

The scheme is optional, and query strings and title slugs are ignored. A URL for the wrong kind of thing, or from another workspace, is reported as such.

#### 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.
Expand Down
6 changes: 5 additions & 1 deletion src/commands/document/document-comment-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
REPLY_TO_DESCRIPTION,
resolveCommentBody,
} from "../../utils/comments.ts"
import { resolveDocumentReference } from "../../utils/linear.ts"

// A document comment attaches to the document's content record, not to the
// document itself, so look that id up first. `document(id:)` accepts a UUID or
Expand All @@ -39,10 +40,13 @@ export const commentAddCommand = new Command()
.option("-b, --body <text:string>", COMMENT_BODY_DESCRIPTION)
.option("--body-file <path:string>", COMMENT_BODY_FILE_DESCRIPTION)
.option("-p, --parent, --reply-to <commentId:string>", REPLY_TO_DESCRIPTION)
.action(async (options, document) => {
.action(async (options, rawDocument) => {
const { body, bodyFile, parent } = options

try {
// Inside the try: resolution rejects a wrong-kind or cross-workspace URL,
// and those errors have to reach handleError like every other failure.
const document = resolveDocumentReference(rawDocument)
const textBody = await resolveCommentBody({ body, bodyFile })

const client = getGraphQLClient()
Expand Down
6 changes: 5 additions & 1 deletion src/commands/document/document-comment-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
collectCommentPages,
renderCommentThreads,
} from "../../utils/comments.ts"
import { resolveDocumentReference } from "../../utils/linear.ts"

// `document(id:)` accepts a UUID or a slug ID, so no resolver is needed.
const GetDocumentComments = gql(`
Expand All @@ -34,10 +35,13 @@ export const commentListCommand = new Command()
.description("List comments on a document (by ID or slug)")
.arguments("<document:string>")
.option("-j, --json", "Output as JSON")
.action(async (options, document) => {
.action(async (options, rawDocument) => {
const { json } = options

try {
// Inside the try: resolution rejects a wrong-kind or cross-workspace URL,
// and those errors have to reach handleError like every other failure.
const document = resolveDocumentReference(rawDocument)
const client = getGraphQLClient()
const comments = await collectCommentPages(async (after) => {
const data = await translateNotFound(
Expand Down
13 changes: 9 additions & 4 deletions src/commands/document/document-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
import { resolveDocumentReference } from "../../utils/linear.ts"

interface DocumentDeleteResult extends BulkOperationResult {
title?: string
Expand Down Expand Up @@ -71,7 +72,7 @@ export const deleteCommand = new Command()
async function handleSingleDelete(
// deno-lint-ignore no-explicit-any
client: any,
documentId: string,
rawDocumentId: string,
options: { yes?: boolean },
): Promise<void> {
const { yes } = options
Expand All @@ -87,10 +88,11 @@ async function handleSingleDelete(
}
`)

const documentId = resolveDocumentReference(rawDocumentId)
const documentDetails = await client.request(detailsQuery, { id: documentId })

if (!documentDetails?.document) {
throw new NotFoundError("Document", documentId)
throw new NotFoundError("Document", rawDocumentId)
}

const document = documentDetails.document
Expand Down Expand Up @@ -189,11 +191,14 @@ async function handleBulkDelete(
}
`)

let documentUuid = docId
const resolvedDocId = resolveDocumentReference(docId)
let documentUuid = resolvedDocId
let title = docId

try {
const details = await client.request(detailsQuery, { id: docId })
const details = await client.request(detailsQuery, {
id: resolvedDocId,
})
if (details?.document) {
documentUuid = details.document.id
title = details.document.title
Expand Down
4 changes: 3 additions & 1 deletion src/commands/document/document-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
toDocumentTargetInput,
} from "./attachment-target.ts"
import { withMarkdownHint } from "../../utils/markdown-help.ts"
import { resolveDocumentReference } from "../../utils/linear.ts"

const GetDocumentForEdit = gql(`
query GetDocumentForEdit($id: String!) {
Expand Down Expand Up @@ -233,9 +234,10 @@ export const updateCommand = new Command()
edit,
force,
},
documentId,
rawDocumentId,
) => {
try {
const documentId = resolveDocumentReference(rawDocumentId)
const targetOptions: DocumentTargetOptions = {
project,
issue,
Expand Down
8 changes: 5 additions & 3 deletions src/commands/document/document-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isNotFoundError,
NotFoundError,
} from "../../utils/errors.ts"
import { resolveDocumentReference } from "../../utils/linear.ts"

const GetDocument = gql(`
query GetDocument($id: String!) {
Expand Down Expand Up @@ -174,13 +175,14 @@ export const viewCommand = new Command()
.option("-w, --web", "Open document in browser")
.option("--json", "Output full document as JSON")
.option("--no-download", "Keep remote URLs instead of downloading files")
.action(async ({ raw, web, json, download }, id) => {
.action(async ({ raw, web, json, download }, rawId) => {
const { Spinner } = await import("@std/cli/unstable-spinner")
const showSpinner = shouldShowSpinner() && !raw && !json
const spinner = showSpinner ? new Spinner() : null
spinner?.start()

try {
const id = resolveDocumentReference(rawId)
spinner?.start()
const client = getGraphQLClient()
const result = json
? { document: await getDocumentWithAllComments(client, id) }
Expand Down Expand Up @@ -293,7 +295,7 @@ export const viewCommand = new Command()
// Report through handleError like every other failure; throwing from
// here would escape the action and print a stack trace instead.
const reported = isClientError(error) && isNotFoundError(error)
? new NotFoundError("Document", id)
? new NotFoundError("Document", rawId)
: error
handleError(reported, "Failed to view document")
}
Expand Down
10 changes: 10 additions & 0 deletions src/commands/initiative-update/initiative-update-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { getGraphQLClient } from "../../utils/graphql.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { withMarkdownHint } from "../../utils/markdown-help.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

const HEALTH_VALUES = ["onTrack", "atRisk", "offTrack"] as const
type HealthValue = (typeof HEALTH_VALUES)[number]
Expand Down Expand Up @@ -43,6 +44,15 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<string | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}

// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/initiative-update/initiative-update-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { handleError, NotFoundError } from "../../utils/errors.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

/**
* Resolve initiative ID from UUID, slug, or name
Expand All @@ -17,6 +18,15 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<string | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}

// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
17 changes: 17 additions & 0 deletions src/commands/initiative/initiative-add-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { CliError, handleError, NotFoundError } from "../../utils/errors.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

const AddProjectToInitiative = gql(`
mutation AddProjectToInitiative($input: InitiativeToProjectCreateInput!) {
Expand All @@ -20,6 +21,14 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<{ id: string; name: string } | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}
// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down Expand Up @@ -99,6 +108,14 @@ async function resolveProjectId(
client: any,
idOrSlugOrName: string,
): Promise<{ id: string; name: string } | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"project",
"a project URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}
// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/initiative/initiative-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

interface InitiativeArchiveResult extends BulkOperationResult {
name: string
Expand Down Expand Up @@ -299,6 +300,15 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<string | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}

// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/initiative/initiative-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

interface InitiativeDeleteResult extends BulkOperationResult {
name: string
Expand Down Expand Up @@ -307,6 +308,15 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<string | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}

// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
17 changes: 17 additions & 0 deletions src/commands/initiative/initiative-remove-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

const GetInitiativeToProjects = gql(`
query GetInitiativeToProjects($first: Int) {
Expand Down Expand Up @@ -39,6 +40,14 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<{ id: string; name: string } | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}
// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down Expand Up @@ -118,6 +127,14 @@ async function resolveProjectId(
client: any,
idOrSlugOrName: string,
): Promise<{ id: string; name: string } | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"project",
"a project URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}
// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/initiative/initiative-unarchive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NotFoundError,
ValidationError,
} from "../../utils/errors.ts"
import { expectLinearUrlKind } from "../../utils/linear-url.ts"

export const unarchiveCommand = new Command()
.name("unarchive")
Expand Down Expand Up @@ -124,6 +125,15 @@ async function resolveInitiativeId(
client: any,
idOrSlugOrName: string,
): Promise<string | undefined> {
const urlRef = expectLinearUrlKind(
idOrSlugOrName,
"initiative",
"an initiative URL, UUID, slug ID, or exact name",
)
if (urlRef != null) {
idOrSlugOrName = urlRef.slugId
}

// Try as UUID first
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
Expand Down
Loading
Loading