├── .eslintignore ├── src ├── index.ts ├── index.d.ts ├── assets │ └── readme-hero.png ├── components │ ├── IssueId.ts │ ├── Label.ts │ ├── index.ts │ ├── Priority.ts │ ├── Status.ts │ ├── Markdown.ts │ ├── IssueCard.ts │ └── IssuesTable.ts ├── commands │ ├── cache │ │ ├── refresh.ts │ │ └── show.ts │ ├── workspace │ │ ├── current.ts │ │ ├── switch.ts │ │ └── add.ts │ ├── config │ │ ├── delete.ts │ │ └── show.ts │ ├── teams │ │ └── show.ts │ ├── issue │ │ ├── create.ts │ │ ├── start.ts │ │ ├── search.ts │ │ ├── stop.ts │ │ ├── index.ts │ │ ├── list.ts │ │ └── update.ts │ └── init.ts ├── lib │ ├── cacheSchema.ts │ ├── handleError.ts │ ├── configSchema.ts │ ├── linear │ │ ├── issueFragment.ts │ │ ├── issuesWithStatus.ts │ │ ├── teamWorkflowStates.ts │ │ ├── issuesFromTeam.ts │ │ ├── allTeams.ts │ │ ├── issuesAllTeams.ts │ │ ├── Linear.ts │ │ ├── issueWorkflowStates.ts │ │ ├── searchIssues.ts │ │ ├── assignedIssues.ts │ │ └── issue.ts │ └── Cache.ts ├── utils │ └── issueId.ts └── base.ts ├── bin ├── run.cmd ├── run └── dev ├── .prettierrc.json ├── .gitignore ├── graphql.config.js ├── .editorconfig ├── codegen.yml ├── tsconfig.json ├── .eslintrc ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/core'; 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'marked-terminal'; 2 | declare module 'wrap-ansi'; 3 | -------------------------------------------------------------------------------- /src/assets/readme-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evangodon/linear-cli/HEAD/src/assets/readme-hero.png -------------------------------------------------------------------------------- /src/components/IssueId.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export const IssueId = (issueId: string) => chalk.bgMagenta(` ${issueId} `); 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 90 7 | } 8 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core'); 4 | 5 | oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /lib 6 | /package-lock.json 7 | /tmp 8 | node_modules 9 | config.json 10 | config.json.backup 11 | .env 12 | .vscode -------------------------------------------------------------------------------- /graphql.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: { 3 | app: { 4 | schema: 'https://api.linear.app/graphql', 5 | documents: ['./src/lib/linear/*.ts'], 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'https://api.linear.app/graphql' 3 | documents: 4 | - 'src/lib/linear/*' 5 | 6 | generates: 7 | src/generated/_documents.ts: 8 | plugins: 9 | - typescript 10 | - typescript-operations 11 | -------------------------------------------------------------------------------- /src/commands/cache/refresh.ts: -------------------------------------------------------------------------------- 1 | import Command from '../../base'; 2 | 3 | export default class CacheRefresh extends Command { 4 | static description = 'Refresh the cache'; 5 | 6 | async run() { 7 | await this.cache.refresh(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Label.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { IssueLabel } from '../generated/_documents'; 3 | 4 | type Label = Pick; 5 | 6 | export const Label = (label: Label) => { 7 | const bullet = chalk.hex(label.color)('•'); 8 | 9 | return `${bullet}${label.name}`; 10 | }; 11 | -------------------------------------------------------------------------------- /src/commands/workspace/current.ts: -------------------------------------------------------------------------------- 1 | import Command from '../../base'; 2 | 3 | export default class WorkspaceCurrent extends Command { 4 | static description = 'Print current workspace'; 5 | 6 | async run() { 7 | this.log(''); 8 | this.log(`Current workspace: ${this.configData.activeWorkspace}`); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/cacheSchema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | const State = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | }); 7 | 8 | const States = z.array(State); 9 | 10 | const Team = z.record(States); 11 | 12 | export const CacheSchema = z.object({ 13 | date: z.string(), 14 | teams: z.record(Team), 15 | }); 16 | 17 | export type CacheData = z.infer; 18 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { Label } from './Label'; 2 | import { Status } from './Status'; 3 | import { Markdown } from './Markdown'; 4 | import { IssuesTable } from './IssuesTable'; 5 | import { Priority } from './Priority'; 6 | import { IssueCard } from './IssueCard'; 7 | import { IssueId } from './IssueId'; 8 | 9 | export const render = { 10 | IssueCard, 11 | IssuesTable, 12 | Label, 13 | Markdown, 14 | Priority, 15 | Status, 16 | IssueId, 17 | }; 18 | -------------------------------------------------------------------------------- /src/commands/config/delete.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Command } from '@oclif/core'; 3 | 4 | /** 5 | */ 6 | export default class ConfigDelete extends Command { 7 | configFilePath = `${this.config.configDir}/config.json`; 8 | 9 | async run() { 10 | try { 11 | await fs.promises.unlink(this.configFilePath); 12 | this.log(`Delete config file at ${this.configFilePath}`); 13 | } catch (error) { 14 | this.error(error.message); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Priority.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | const priorityLevel: { [key: number]: string } = { 4 | 0: chalk.dim('—'), 5 | 1: `${chalk.red('!!!')} Urgent`, 6 | 2: `■■■ High`, 7 | 3: `■■☐ Medium`, 8 | 4: `■☐☐ Low`, 9 | }; 10 | 11 | /** 12 | * Renders the priority of an issue 13 | * 14 | * @param {Status} - state 15 | * @returns {string} - status 16 | */ 17 | export const Priority = (priority: number) => { 18 | return priorityLevel[priority] ?? ''; 19 | }; 20 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core'); 4 | 5 | const path = require('path'); 6 | const project = path.join(__dirname, '..', 'tsconfig.json'); 7 | 8 | // In dev mode -> use ts-node and dev plugins 9 | process.env.NODE_ENV = 'development'; 10 | 11 | require('ts-node').register({ project }); 12 | 13 | // In dev mode, always show stack traces 14 | oclif.settings.debug = true; 15 | 16 | // Start the CLI 17 | oclif.run().then(oclif.flush).catch(oclif.Errors.handle); 18 | -------------------------------------------------------------------------------- /src/lib/handleError.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { InvalidInputLinearError } from '@linear/sdk'; 3 | 4 | /** 5 | * @todo: handle linear errors and other types 6 | * 7 | * @param {Error} error - Error object 8 | */ 9 | export const handleError = (error: Error) => { 10 | if (error instanceof InvalidInputLinearError) { 11 | process.stderr.write(error.message); 12 | process.exit(1); 13 | } 14 | 15 | process.stderr.write(`\n${chalk.red('Error: ')} ${error.message}`); 16 | 17 | process.exit(1); 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "lib", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017", 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "strictNullChecks": true, 13 | "useUnknownInCatchVariables": false, 14 | "paths": { 15 | "generated/*": ["./src/generated/*"] 16 | } 17 | }, 18 | "include": ["src/**/*", "src/index.d.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/cache/show.ts: -------------------------------------------------------------------------------- 1 | import Command, { Flags } from '../../base'; 2 | 3 | export default class CacheShow extends Command { 4 | static description = 'Print the cache file'; 5 | 6 | static flags = { 7 | pretty: Flags.boolean({ char: 'p', description: 'Pretty print' }), 8 | }; 9 | 10 | async run() { 11 | const { flags } = await this.parse(CacheShow); 12 | const cache = await this.cache.read(); 13 | 14 | if (flags.pretty) { 15 | this.log(JSON.stringify(cache, null, 2)); 16 | return; 17 | } 18 | 19 | this.log(JSON.stringify(cache)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Status.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { WorkflowState } from '../generated/_documents'; 3 | 4 | type Status = Pick; 5 | 6 | const char: { [key: string]: string } = { 7 | triage: '↔', 8 | backlog: '◌', 9 | unstarted: '○', 10 | started: '◑', 11 | completed: '✓', 12 | canceled: '⍉', 13 | }; 14 | 15 | /** 16 | * Renders a status from an issue 17 | * 18 | * @param {Status} - state 19 | * @returns {string} - status 20 | */ 21 | export const Status = (state: Status) => { 22 | const box = chalk.hex(state.color)(char[state.type]); 23 | 24 | return `${box} ${state.name}`; 25 | }; 26 | -------------------------------------------------------------------------------- /src/commands/config/show.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Command } from '@oclif/core'; 3 | import chalk from 'chalk'; 4 | 5 | /** 6 | * Read and show config file 7 | */ 8 | export default class ConfigShow extends Command { 9 | configFilePath = `${this.config.configDir}/config.json`; 10 | 11 | async run() { 12 | try { 13 | const configJSON = fs.readFileSync(this.configFilePath, { 14 | encoding: 'utf8', 15 | }); 16 | 17 | this.log(configJSON); 18 | this.log(''); 19 | this.log(`Config file path: ${chalk.magenta(this.configFilePath)}`); 20 | } catch (error) { 21 | this.error(error.message); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "oclif", 4 | "oclif-typescript", 5 | "prettier", 6 | "plugin:import/errors", 7 | "plugin:import/typescript" 8 | ], 9 | "rules": { 10 | "import/no-cycle": 1, 11 | "no-warning-comments": 0, 12 | "unicorn/filename-case": 0, 13 | "unicorn/no-process-exit": 0, 14 | "no-process-exit": 0, 15 | "unicorn/no-hex-escape": 0, 16 | "new-cap": 0, 17 | "valid-jsdoc": 0, 18 | "unicorn/no-abusive-eslint-disable": 0, 19 | "no-dupe-else-if": 0, 20 | "no-import-assign": 0, 21 | "no-setter-return": 0, 22 | "complexity": 0, 23 | "node/no-unpublished-require": 0, 24 | "import/no-unresolved": 0 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/configSchema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | const User = z.object({ 4 | id: z.string(), 5 | name: z.string(), 6 | email: z.string(), 7 | }); 8 | 9 | const Workplace = z.object({ 10 | apiKey: z.string(), 11 | defaultTeam: z.string(), 12 | user: User, 13 | }); 14 | 15 | const Workspaces = z.record(Workplace); 16 | 17 | export const Config = z 18 | .object({ 19 | activeWorkspace: z.string(), 20 | workspaces: Workspaces, 21 | }) 22 | .refine((config) => Object.keys(config.workspaces).includes(config.activeWorkspace), { 23 | message: 'The current active workspace was not found in your config file.', 24 | }); 25 | 26 | export type Config = z.infer; 27 | export type User = z.infer; 28 | export type Workspace = z.infer; 29 | -------------------------------------------------------------------------------- /src/lib/linear/issueFragment.ts: -------------------------------------------------------------------------------- 1 | const gql = String.raw; 2 | 3 | export const IssueConnectionFragment = gql` 4 | fragment IssueConnection on IssueConnection { 5 | nodes { 6 | ...Issue 7 | } 8 | } 9 | fragment Issue on Issue { 10 | url 11 | identifier 12 | title 13 | createdAt 14 | updatedAt 15 | parent { 16 | id 17 | } 18 | priority 19 | priorityLabel 20 | project { 21 | id 22 | } 23 | team { 24 | id 25 | key 26 | } 27 | id 28 | assignee { 29 | id 30 | displayName 31 | } 32 | state { 33 | id 34 | name 35 | color 36 | type 37 | } 38 | labels { 39 | nodes { 40 | id 41 | name 42 | color 43 | } 44 | } 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /src/utils/issueId.ts: -------------------------------------------------------------------------------- 1 | export const issueArgs = [ 2 | { name: 'issueId', required: true }, 3 | { 4 | name: 'issueIdOptional', 5 | hidden: true, 6 | description: 'Use this if you want to split the issue id into two arguments', 7 | }, 8 | ]; 9 | 10 | export type IssueArgs = { issueId: string; issueIdOptional?: string }; 11 | 12 | // TODO: remove any below 13 | type GetIssueId = (args: any | { issueId: string; issueIdOptional?: string }) => string; 14 | 15 | export const getIssueId: GetIssueId = (args) => { 16 | const { issueId, issueIdOptional } = args; 17 | 18 | if (issueId.match(/^\d*$/)) { 19 | return `${global.currentWorkspace.defaultTeam}-${issueId}`; 20 | } 21 | 22 | if (issueIdOptional) { 23 | return `${args.issueId}-${args.issueIdOptional}`; 24 | } 25 | 26 | return issueId; 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/linear/issuesWithStatus.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { handleError } from '../handleError'; 4 | import { StatusIssuesQuery, StatusIssuesQueryVariables } from 'generated/_documents'; 5 | import { IssueConnectionFragment } from './issueFragment'; 6 | 7 | const gql = String.raw; 8 | 9 | const statusIssuesQuery = gql` 10 | query statusIssues($id: String!) { 11 | workflowState(id: $id) { 12 | issues { 13 | ...IssueConnection 14 | } 15 | } 16 | } 17 | ${IssueConnectionFragment} 18 | `; 19 | 20 | /** 21 | * Get all issues of status 22 | */ 23 | export const issuesWithStatus = (client: LinearGraphQLClient) => { 24 | return async (statusId: string) => { 25 | const spinner = ora().start(); 26 | 27 | const { data } = await client 28 | .rawRequest(statusIssuesQuery, { 29 | id: statusId, 30 | }) 31 | .catch((error) => handleError(error)) 32 | .finally(() => spinner.stop()); 33 | 34 | if (!data) { 35 | throw new Error('No data returned from Linear'); 36 | } 37 | 38 | return data.workflowState.issues.nodes; 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/lib/linear/teamWorkflowStates.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { handleError } from '../handleError'; 4 | import { 5 | TeamWorkflowStatesQuery, 6 | TeamWorkflowStatesQueryVariables, 7 | } from 'generated/_documents'; 8 | 9 | const gql = String.raw; 10 | 11 | const teamWorkflowStatesQuery = gql` 12 | query teamWorkflowStates { 13 | teams { 14 | nodes { 15 | id 16 | name 17 | key 18 | states { 19 | nodes { 20 | id 21 | name 22 | } 23 | } 24 | } 25 | } 26 | } 27 | `; 28 | 29 | /** 30 | * Get all possible workflow states of an issue 31 | */ 32 | export const teamWorkflowStates = (client: LinearGraphQLClient) => { 33 | return async () => { 34 | const spinner = ora().start(); 35 | 36 | const { data } = await client 37 | .rawRequest( 38 | teamWorkflowStatesQuery 39 | ) 40 | .catch((error) => handleError(error)) 41 | .finally(() => spinner.stop()); 42 | 43 | if (!data) { 44 | throw new Error('No data returned from Linear'); 45 | } 46 | 47 | return data; 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/commands/workspace/switch.ts: -------------------------------------------------------------------------------- 1 | import * as inquirer from 'inquirer'; 2 | import fs from 'fs'; 3 | import chalk from 'chalk'; 4 | import Command from '../../base'; 5 | import { Config } from '../../lib/configSchema'; 6 | 7 | type PromptResponse = { 8 | workspace: string; 9 | }; 10 | 11 | export default class WorkspaceSwitch extends Command { 12 | static description = 'Switch to another workspace'; 13 | 14 | async run() { 15 | const response = await inquirer.prompt([ 16 | { 17 | name: 'workspace', 18 | message: 'Select workspace', 19 | type: 'list', 20 | choices: Object.keys(this.configData.workspaces).map((workspace) => 21 | workspace === this.configData.activeWorkspace 22 | ? { name: `${workspace} (current)`, value: workspace } 23 | : workspace 24 | ), 25 | }, 26 | ]); 27 | 28 | const newConfig: Config = { 29 | ...this.configData, 30 | activeWorkspace: response.workspace, 31 | }; 32 | 33 | await fs.promises.writeFile(this.configFilePath, JSON.stringify(newConfig, null, 2), { 34 | flag: 'w', 35 | }); 36 | 37 | this.log(''); 38 | this.success(`Switched to ${chalk.magenta(response.workspace)} workspace`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/teams/show.ts: -------------------------------------------------------------------------------- 1 | import { cli } from 'cli-ux'; 2 | import ora from 'ora'; 3 | import { TeamConnection } from '@linear/sdk'; 4 | import Command, { Flags } from '../../base'; 5 | 6 | type Team = { 7 | key?: string; 8 | name?: string; 9 | }; 10 | 11 | export default class TeamsShow extends Command { 12 | static description = 'Show teams in this workspace'; 13 | 14 | static flags = { 15 | mine: Flags.boolean({ char: 'm', description: 'Pretty print' }), 16 | }; 17 | 18 | async run() { 19 | const { flags } = await this.parse(TeamsShow); 20 | const spinner = ora('Loading issues').start(); 21 | 22 | let data: TeamConnection | undefined; 23 | 24 | if (flags.mine) { 25 | const user = await this.linear.user(this.user.id); 26 | data = await user?.teams(); 27 | } else { 28 | data = await this.linear.teams(); 29 | } 30 | 31 | spinner.stop(); 32 | 33 | if (!data || !data.nodes) { 34 | this.error('Failed to fetch teams'); 35 | } 36 | 37 | const teams: Team[] = data.nodes.map((team) => ({ key: team.key, name: team.name })); 38 | 39 | cli.table( 40 | teams, 41 | { 42 | key: { 43 | minWidth: 10, 44 | }, 45 | name: {}, 46 | }, 47 | { printLine: this.log } 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Markdown.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import marked from 'marked'; 4 | import TerminalRenderer from 'marked-terminal'; 5 | import terminalLink from 'terminal-link'; 6 | import wrapAnsi from 'wrap-ansi'; 7 | 8 | /** 9 | * Reads markdown and renders it for the terminal 10 | */ 11 | 12 | const MAX_WIDTH = 90; 13 | 14 | let imageCounter = 1; 15 | 16 | marked.setOptions({ 17 | renderer: new TerminalRenderer({ 18 | reflowText: true, 19 | width: MAX_WIDTH, 20 | link: (href: string) => { 21 | /* Remove email links */ 22 | if (href.match(/@/)) { 23 | return href.split(' ')[0]; 24 | } 25 | return href; 26 | }, 27 | image: (href: string, title: string) => { 28 | const linkId = imageCounter++; 29 | const mediaType = href.match(/[.png|.jpg]$/) ? 'IMAGE' : 'MEDIA'; 30 | /* Print at the end */ 31 | setTimeout(() => { 32 | global.log(`\n[${linkId}] ${terminalLink(title, href)}`); 33 | }, 0); 34 | 35 | return `[${mediaType}][${linkId}] ${title}`; 36 | }, 37 | }), 38 | mangle: false, 39 | smartLists: true, 40 | }); 41 | 42 | export const Markdown = (markdown: string) => { 43 | markdown = marked(markdown).replace(/\*/g, () => `•`); 44 | markdown = wrapAnsi(markdown, 90); 45 | 46 | return markdown; 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/linear/issuesFromTeam.ts: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import { LinearGraphQLClient } from '@linear/sdk'; 3 | import { TeamIssuesQuery, TeamIssuesQueryVariables } from 'generated/_documents'; 4 | 5 | import { IssueConnectionFragment } from './issueFragment'; 6 | import { handleError } from '../handleError'; 7 | 8 | const gql = String.raw; 9 | 10 | const teamIssuesQuery = gql` 11 | query teamIssues($teamId: String!, $first: Int!) { 12 | team(id: $teamId) { 13 | issues(first: $first, orderBy: updatedAt) { 14 | ...IssueConnection 15 | } 16 | name 17 | } 18 | } 19 | ${IssueConnectionFragment} 20 | `; 21 | 22 | type Params = { 23 | teamId: string; 24 | first: number; 25 | }; 26 | 27 | /** 28 | * Get issues from one team 29 | */ 30 | export const issuesFromTeams = (client: LinearGraphQLClient) => { 31 | return async ({ teamId, first }: Params) => { 32 | const spinner = ora('Loading issues').start(); 33 | 34 | const { data } = await client 35 | .rawRequest(teamIssuesQuery, { 36 | teamId, 37 | first, 38 | }) 39 | .catch(handleError) 40 | .finally(() => spinner.stop()); 41 | 42 | if (!data) { 43 | throw new Error('No data returned from Linear'); 44 | } 45 | 46 | return data.team.issues.nodes; 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/linear/allTeams.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | 4 | import { TeamsQuery, TeamsQueryVariables } from 'generated/_documents'; 5 | import { handleError } from '../handleError'; 6 | 7 | const gql = String.raw; 8 | 9 | const teamsQuery = gql` 10 | query teams( 11 | $after: String 12 | $before: String 13 | $first: Int 14 | $includeArchived: Boolean 15 | $last: Int 16 | $orderBy: PaginationOrderBy 17 | ) { 18 | teams( 19 | after: $after 20 | before: $before 21 | first: $first 22 | includeArchived: $includeArchived 23 | last: $last 24 | orderBy: $orderBy 25 | ) { 26 | ...TeamConnection 27 | } 28 | } 29 | fragment TeamConnection on TeamConnection { 30 | nodes { 31 | ...Team 32 | } 33 | } 34 | fragment Team on Team { 35 | description 36 | name 37 | key 38 | id 39 | } 40 | `; 41 | 42 | /** Get all teams */ 43 | export const allTeams = (client: LinearGraphQLClient) => { 44 | return async () => { 45 | const spinner = ora().start(); 46 | 47 | const { data } = await client 48 | .rawRequest(teamsQuery) 49 | .catch((error) => handleError(error)); 50 | 51 | spinner.stop(); 52 | 53 | if (!data) { 54 | throw new Error('No data returned from Linear'); 55 | } 56 | 57 | return data.teams.nodes; 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/lib/linear/issuesAllTeams.ts: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import { LinearDocument, LinearGraphQLClient } from '@linear/sdk'; 3 | import { handleError } from '../handleError'; 4 | import { IssuesQuery, IssuesQueryVariables } from 'generated/_documents'; 5 | 6 | import { IssueConnectionFragment } from './issueFragment'; 7 | 8 | const gql = String.raw; 9 | 10 | const issuesQuery = gql` 11 | query issues( 12 | $after: String 13 | $before: String 14 | $first: Int 15 | $includeArchived: Boolean 16 | $last: Int 17 | $orderBy: PaginationOrderBy 18 | ) { 19 | issues( 20 | after: $after 21 | before: $before 22 | first: $first 23 | includeArchived: $includeArchived 24 | last: $last 25 | orderBy: $orderBy 26 | ) { 27 | ...IssueConnection 28 | } 29 | } 30 | ${IssueConnectionFragment} 31 | `; 32 | 33 | /** 34 | * Get issues from all teams 35 | */ 36 | export const issuesAllTeams = (client: LinearGraphQLClient) => { 37 | return async () => { 38 | const spinner = ora('Loading issues').start(); 39 | 40 | const { data } = await client 41 | .rawRequest(issuesQuery, { 42 | first: 50, 43 | orderBy: LinearDocument.PaginationOrderBy.CreatedAt, 44 | }) 45 | .catch(handleError); 46 | 47 | spinner.stop(); 48 | 49 | if (!data) { 50 | throw new Error('No data returned from Linear'); 51 | } 52 | 53 | return data.issues.nodes; 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/lib/linear/Linear.ts: -------------------------------------------------------------------------------- 1 | import { LinearClient } from '@linear/sdk'; 2 | import { User } from '../configSchema'; 3 | import { issuesAllTeams } from './issuesAllTeams'; 4 | import { issuesFromTeams } from './issuesFromTeam'; 5 | import { issue } from './issue'; 6 | import { allTeams } from './allTeams'; 7 | import { issueWorkflowStates } from './issueWorkflowStates'; 8 | import { teamWorkflowStates } from './teamWorkflowStates'; 9 | import { issuesWithStatus } from './issuesWithStatus'; 10 | import { assignedIssues } from './assignedIssues'; 11 | import { searchIssues } from './searchIssues'; 12 | 13 | type UserInfo = { 14 | apiKey: string; 15 | currentUser: User; 16 | }; 17 | 18 | /** 19 | * Custom Linear client 20 | */ 21 | export class Linear extends LinearClient { 22 | currentUser: User = (null as unknown) as User; 23 | 24 | constructor({ apiKey, currentUser }: UserInfo) { 25 | super({ apiKey }); 26 | 27 | this.currentUser = currentUser; 28 | } 29 | 30 | get query() { 31 | return { 32 | allTeams: allTeams(this.client), 33 | assignedIssues: assignedIssues(this.client), 34 | issue: issue(this.client), 35 | issueWorkflowStates: issueWorkflowStates(this.client), 36 | issuesAllTeams: issuesAllTeams(this.client), 37 | issuesFromTeam: issuesFromTeams(this.client), 38 | issuesWithStatus: issuesWithStatus(this.client), 39 | searchIssues: searchIssues(this.client), 40 | teamWorkflowStates: teamWorkflowStates(this.client), 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/linear/issueWorkflowStates.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { 4 | GetIssueWorkflowStatesQuery, 5 | GetIssueWorkflowStatesQueryVariables, 6 | } from 'generated/_documents'; 7 | import { handleError } from '../handleError'; 8 | 9 | const gql = String.raw; 10 | 11 | const issueWorkflowStatesQuery = gql` 12 | query getIssueWorkflowStates($id: String!) { 13 | issue(id: $id) { 14 | identifier 15 | team { 16 | id 17 | name 18 | states { 19 | nodes { 20 | id 21 | name 22 | type 23 | color 24 | position 25 | } 26 | } 27 | } 28 | id 29 | assignee { 30 | id 31 | name 32 | displayName 33 | } 34 | state { 35 | id 36 | name 37 | type 38 | color 39 | } 40 | } 41 | } 42 | `; 43 | 44 | /** 45 | * Get all possible workflow states of an issue 46 | */ 47 | export const issueWorkflowStates = (client: LinearGraphQLClient) => { 48 | return async (issueId: string) => { 49 | const spinner = ora().start(); 50 | 51 | const { data } = await client 52 | .rawRequest( 53 | issueWorkflowStatesQuery, 54 | { 55 | id: issueId, 56 | } 57 | ) 58 | .catch(handleError) 59 | .finally(() => spinner.stop()); 60 | 61 | if (!data) { 62 | throw new Error('No data returned from Linear'); 63 | } 64 | 65 | return data.issue; 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/lib/linear/searchIssues.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { handleError } from '../handleError'; 4 | import { IssueSearchQuery } from '@linear/sdk/dist/_generated_documents'; 5 | import { IssueSearchQueryVariables } from 'generated/_documents'; 6 | 7 | const gql = String.raw; 8 | 9 | const searchIssuesQuery = gql` 10 | query issueSearch( 11 | $after: String 12 | $before: String 13 | $first: Int 14 | $includeArchived: Boolean 15 | $last: Int 16 | $orderBy: PaginationOrderBy 17 | $query: String! 18 | ) { 19 | issueSearch( 20 | after: $after 21 | before: $before 22 | first: $first 23 | includeArchived: $includeArchived 24 | last: $last 25 | orderBy: $orderBy 26 | query: $query 27 | ) { 28 | nodes { 29 | id 30 | title 31 | identifier 32 | } 33 | } 34 | } 35 | `; 36 | 37 | type Options = { 38 | noSpinner?: boolean; 39 | }; 40 | 41 | /** 42 | * Search issues 43 | */ 44 | export const searchIssues = (client: LinearGraphQLClient) => { 45 | return async (queryString: string, { noSpinner }: Options = {}) => { 46 | const spinner = ora({ isEnabled: !noSpinner }).start(); 47 | const { data } = await client 48 | .rawRequest(searchIssuesQuery, { 49 | query: queryString, 50 | }) 51 | .catch((error) => handleError(error)); 52 | 53 | spinner.stop(); 54 | 55 | if (!data) { 56 | throw new Error('No data returned from Linear'); 57 | } 58 | 59 | return data.issueSearch.nodes; 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /src/lib/linear/assignedIssues.ts: -------------------------------------------------------------------------------- 1 | import { LinearDocument, LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { 4 | User_AssignedIssuesQuery, 5 | User_AssignedIssuesQueryVariables, 6 | } from 'generated/_documents'; 7 | 8 | import { IssueConnectionFragment } from './issueFragment'; 9 | import { handleError } from '../handleError'; 10 | 11 | const gql = String.raw; 12 | 13 | const assignedIssuesQuery = gql` 14 | query user_assignedIssues( 15 | $id: String! 16 | $after: String 17 | $before: String 18 | $first: Int 19 | $includeArchived: Boolean 20 | $last: Int 21 | $orderBy: PaginationOrderBy 22 | ) { 23 | user(id: $id) { 24 | assignedIssues( 25 | after: $after 26 | before: $before 27 | first: $first 28 | includeArchived: $includeArchived 29 | last: $last 30 | orderBy: $orderBy 31 | ) { 32 | ...IssueConnection 33 | } 34 | } 35 | } 36 | ${IssueConnectionFragment} 37 | `; 38 | 39 | type Params = { 40 | disableSpinner?: boolean; 41 | }; 42 | 43 | /** Get issues assigned to user */ 44 | export const assignedIssues = (client: LinearGraphQLClient) => { 45 | return async ({ disableSpinner }: Params = {}) => { 46 | const spinner = ora({ isEnabled: !disableSpinner }).start(); 47 | 48 | const { data } = await client 49 | .rawRequest( 50 | assignedIssuesQuery, 51 | { 52 | id: global.user.id, 53 | first: 20, 54 | orderBy: LinearDocument.PaginationOrderBy.UpdatedAt, 55 | } 56 | ) 57 | .catch(handleError) 58 | .finally(() => spinner.stop()); 59 | 60 | if (!data) { 61 | throw new Error('No data returned from Linear'); 62 | } 63 | 64 | return data.user.assignedIssues.nodes; 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/commands/issue/create.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import clipboardy from 'clipboardy'; 3 | import Command, { Flags } from '../../base'; 4 | import chalk from 'chalk'; 5 | import ora from 'ora'; 6 | 7 | type Response = { 8 | teamId: string; 9 | title: string; 10 | description: string; 11 | }; 12 | 13 | /** 14 | */ 15 | export default class IssueCreate extends Command { 16 | static description = 'Create a new issue'; 17 | 18 | static aliases = ['create', 'c']; 19 | 20 | static flags = { 21 | copy: Flags.boolean({ 22 | char: 'c', 23 | description: 'Copy issue url to clipboard after creating', 24 | }), 25 | }; 26 | 27 | async run() { 28 | const { flags } = await this.parse(IssueCreate); 29 | 30 | const teams = await this.linear.query.allTeams(); 31 | 32 | const { teamId, title, description } = await inquirer.prompt([ 33 | { 34 | name: 'teamId', 35 | message: 'For which team?', 36 | type: 'list', 37 | choices: teams.map((team) => ({ name: team.name, value: team.id })), 38 | }, 39 | { 40 | name: 'title', 41 | message: 'Title', 42 | type: 'input', 43 | }, 44 | { 45 | name: 'description', 46 | message: 'Description', 47 | type: 'editor', 48 | }, 49 | ]); 50 | 51 | const spinner = ora('Creating issue').start(); 52 | const response = await this.linear.issueCreate({ teamId, title, description }); 53 | 54 | spinner.stop(); 55 | 56 | if (!response) { 57 | this.error('Something went wrong, issue creation failed.'); 58 | } 59 | 60 | const newIssue = await response.issue; 61 | 62 | if (!newIssue) { 63 | this.error('Something went wrong getting new issue'); 64 | } 65 | 66 | this.log(''); 67 | this.success(`Issue ${chalk.magenta(newIssue.identifier)} created: ${newIssue.url}`); 68 | 69 | if (flags.copy) { 70 | if (!newIssue.url) { 71 | this.error('Error getting issue url for clipboard'); 72 | } 73 | clipboardy.writeSync(newIssue.url); 74 | this.log('Url copied to clipboard'); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/Cache.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Config as IConfig } from '@oclif/core'; 3 | import { CacheSchema, CacheData } from './cacheSchema'; 4 | import { Config } from './configSchema'; 5 | import { Linear } from './linear/Linear'; 6 | 7 | type Params = { 8 | config: IConfig; 9 | configData: Config; 10 | linear: Linear; 11 | }; 12 | 13 | export class Cache { 14 | config: IConfig; 15 | 16 | configData: Config; 17 | 18 | linear: Linear; 19 | 20 | cachePath: string; 21 | 22 | constructor({ config, configData, linear }: Params) { 23 | this.config = config; 24 | this.linear = linear; 25 | this.configData = configData; 26 | this.cachePath = `${config.cacheDir}/${configData.activeWorkspace}.json`; 27 | } 28 | 29 | makeCachePath(workspace: string) { 30 | return `${this.config.cacheDir}/${workspace}.json`; 31 | } 32 | 33 | exists(workspace: string = this.configData.activeWorkspace) { 34 | const path = this.makeCachePath(workspace); 35 | return fs.existsSync(path); 36 | } 37 | 38 | async read(): Promise { 39 | let cache: CacheData = { teams: {}, date: '' }; 40 | 41 | if (!this.exists()) { 42 | await this.refresh(); 43 | } 44 | 45 | try { 46 | const cacheJson = fs.readFileSync(this.cachePath, { 47 | encoding: 'utf8', 48 | }); 49 | 50 | const cacheUnknown: unknown = JSON.parse(cacheJson); 51 | 52 | cache = CacheSchema.parse(cacheUnknown); 53 | } catch (error) { 54 | console.error(error); 55 | } 56 | 57 | return cache; 58 | } 59 | 60 | async refresh() { 61 | const data = await this.linear.query.teamWorkflowStates(); 62 | 63 | const cache: CacheData = data.teams.nodes.reduce( 64 | (acc: CacheData, currentTeam) => { 65 | acc.teams[currentTeam.key] = { 66 | states: currentTeam.states.nodes.map((state) => state), 67 | }; 68 | 69 | return acc; 70 | }, 71 | { date: new Date().toString(), teams: {} } 72 | ); 73 | 74 | try { 75 | await fs.promises.writeFile(this.cachePath, JSON.stringify(cache, null, 2), { 76 | flag: 'w', 77 | }); 78 | } catch (error) { 79 | console.warn(`Failed to write cache data to ${this.cachePath}`); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/issue/start.ts: -------------------------------------------------------------------------------- 1 | import clipboardy from 'clipboardy'; 2 | import chalk from 'chalk'; 3 | import inquirer from 'inquirer'; 4 | import Command, { Flags } from '../../base'; 5 | import { issueArgs, getIssueId, IssueArgs } from '../../utils/issueId'; 6 | import { render } from '../../components'; 7 | 8 | export default class IssueStart extends Command { 9 | static description = 'Change status of issue to "In progress" and assign to yourself.'; 10 | 11 | static aliases = ['start', 's']; 12 | 13 | static args = issueArgs; 14 | 15 | static flags = { 16 | 'copy-branch': Flags.boolean({ 17 | char: 'c', 18 | description: 'copy git branch to clip-board', 19 | }), 20 | }; 21 | 22 | async run() { 23 | const { args, flags } = await this.parse(IssueStart); 24 | 25 | const issueId = getIssueId(args); 26 | 27 | const currentIssue = await this.linear.query.issue(issueId); 28 | 29 | if (currentIssue.assignee && currentIssue.assignee.id !== this.user.id) { 30 | const { confirmAssign } = await inquirer.prompt<{ confirmAssign: boolean }>([ 31 | { 32 | name: 'confirmAssign', 33 | message: `Issue ${render.IssueId(currentIssue.identifier)} is assigned to ${ 34 | currentIssue.assignee.displayName 35 | }, do you want to assign to yourself?`, 36 | type: 'confirm', 37 | }, 38 | ]); 39 | 40 | if (!confirmAssign) { 41 | return; 42 | } 43 | } 44 | 45 | const nextState = currentIssue.team.states.nodes 46 | .filter((state) => state.type === 'started') 47 | .sort((s1, s2) => (s1.position > s2.position ? 1 : -1))[0]; 48 | 49 | await this.linear.issueUpdate(currentIssue.identifier, { 50 | stateId: nextState.id, 51 | assigneeId: this.user.id, 52 | }); 53 | 54 | this.log(''); 55 | this.success( 56 | `The state of issue ${render.IssueId( 57 | currentIssue.identifier 58 | )} is now in the ${render.Status(nextState)} state and is assigned to you.` 59 | ); 60 | 61 | if (flags['copy-branch']) { 62 | const gitBranch = `${currentIssue.identifier}/${currentIssue.title.replace( 63 | /\s/g, 64 | '-' 65 | )}`.toLowerCase(); 66 | clipboardy.writeSync(gitBranch); 67 | this.log(`${chalk.blue(gitBranch)} branch copied to clipboard`); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/issue/search.ts: -------------------------------------------------------------------------------- 1 | import { cli } from 'cli-ux'; 2 | import Command from '../../base'; 3 | import inquirer from 'inquirer'; 4 | import { render } from '../../components'; 5 | import { IssueSearchQuery } from '../../generated/_documents'; 6 | 7 | inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); 8 | 9 | type SearchedIssue = IssueSearchQuery['issueSearch']['nodes'][0]; 10 | 11 | /** 12 | * TODO: Debounce requests when searching 13 | */ 14 | export default class IssueSearch extends Command { 15 | static aliases = ['search', 's']; 16 | 17 | static description = 'describe the command here'; 18 | 19 | static args = [{ name: 'query' }]; 20 | 21 | async promptSearch() { 22 | const response = await inquirer.prompt<{ 23 | issue: SearchedIssue; 24 | }>([ 25 | { 26 | type: 'autocomplete', 27 | name: 'issue', 28 | message: 'Search issues', 29 | emptyText: 'No issues found', 30 | pageSize: 20, 31 | source: async (_: any, input: string) => { 32 | if (!input) { 33 | return []; 34 | } 35 | 36 | const issues = await this.linear.query.searchIssues(input, { noSpinner: true }); 37 | return issues?.map((issue) => ({ 38 | name: `${issue.identifier} - ${issue.title}`, 39 | value: issue, 40 | })); 41 | }, 42 | }, 43 | ]); 44 | 45 | const selectedIssue = await this.linear.query.issue(response.issue.id); 46 | 47 | render.IssueCard(selectedIssue); 48 | } 49 | 50 | async searchWithQuery(query: string) { 51 | const issues = await this.linear.query.searchIssues(query); 52 | 53 | if (issues.length === 0) { 54 | this.log('No issues found'); 55 | return; 56 | } 57 | 58 | this.log(''); 59 | cli.table( 60 | issues, 61 | { 62 | identifier: { 63 | get: (issue) => issue.identifier, 64 | }, 65 | title: { 66 | header: 'Status', 67 | get: (issue) => issue.title, 68 | }, 69 | }, 70 | { 71 | printLine: this.log, 72 | 'no-header': true, 73 | } 74 | ); 75 | } 76 | 77 | async run() { 78 | const { args } = await this.parse<{}, { query: string }>(IssueSearch); 79 | 80 | if (args.query) { 81 | await this.searchWithQuery(args.query); 82 | return; 83 | } 84 | 85 | await this.promptSearch(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/workspace/add.ts: -------------------------------------------------------------------------------- 1 | import { LinearClient } from '@linear/sdk'; 2 | import chalk from 'chalk'; 3 | import fs from 'fs'; 4 | import * as inquirer from 'inquirer'; 5 | import Command from '../../base'; 6 | import { Workspace, Config } from '../../lib/configSchema'; 7 | 8 | type PromptResponse = { 9 | apiKey: string; 10 | label: string; 11 | }; 12 | 13 | export default class WorkspaceAdd extends Command { 14 | static description = 'Add a new workplace'; 15 | 16 | async run() { 17 | const response = await inquirer.prompt([ 18 | { 19 | name: 'apiKey', 20 | message: 'Paste your Linear api key here:', 21 | }, 22 | { 23 | name: 'label', 24 | message: 'Create a label for this key', 25 | validate: (input: string) => { 26 | if (input.trim().length === 0) { 27 | return 'The label needs to be at least one character'; 28 | } 29 | return true; 30 | }, 31 | }, 32 | ]); 33 | 34 | const linearClient = new LinearClient({ apiKey: response.apiKey }); 35 | 36 | const user = await linearClient.viewer; 37 | 38 | if (!user) { 39 | throw new Error('Invalid Linear api key'); 40 | } 41 | 42 | if (!user.id) { 43 | throw new Error('Failed to get user id'); 44 | } 45 | 46 | const teamConnection = await linearClient.teams(); 47 | 48 | if (!teamConnection) { 49 | this.error('Failed to get your teams'); 50 | } 51 | 52 | const teams = teamConnection.nodes?.map((team) => ({ 53 | id: team.id, 54 | name: team.name, 55 | value: team.key, 56 | })); 57 | 58 | const { defaultTeam } = await inquirer.prompt<{ defaultTeam: string }>([ 59 | { 60 | name: 'defaultTeam', 61 | message: 'Select your default team', 62 | type: 'list', 63 | choices: teams, 64 | }, 65 | ]); 66 | 67 | const newWorkplace: Workspace = { 68 | apiKey: response.apiKey, 69 | defaultTeam, 70 | user: { 71 | id: user.id, 72 | name: user.name!, 73 | email: user.email!, 74 | }, 75 | }; 76 | 77 | const newConfig: Config = { 78 | ...this.configData, 79 | workspaces: { 80 | ...this.configData.workspaces, 81 | [response.label]: newWorkplace, 82 | }, 83 | }; 84 | 85 | await fs.promises.writeFile(this.configFilePath, JSON.stringify(newConfig, null, 2), { 86 | flag: 'w', 87 | }); 88 | 89 | this.log(''); 90 | this.success(`Workspace with label ${chalk.blue(response.label)} added.`); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | import fs from 'fs'; 3 | import chalk from 'chalk'; 4 | import { Linear } from './lib/linear/Linear'; 5 | import { Config, User, Workspace } from './lib/configSchema'; 6 | import { Cache } from './lib/Cache'; 7 | 8 | declare global { 9 | var currentWorkspace: Workspace; 10 | var user: User; 11 | var log: (message?: string | undefined, ...args: any[]) => void; 12 | } 13 | 14 | export default abstract class extends Command { 15 | configFilePath = `${this.config.configDir}/config.json`; 16 | 17 | linear: Linear = null as unknown as Linear; 18 | 19 | user: User = null as unknown as User; 20 | 21 | currentWorkspace: Workspace = null as unknown as Workspace; 22 | 23 | configData: Config = null as unknown as Config; 24 | 25 | cache: Cache = null as unknown as Cache; 26 | 27 | async init() { 28 | const configFilePath = `${this.config.configDir}/config.json`; 29 | 30 | try { 31 | const configJSON = fs.readFileSync(configFilePath, { 32 | encoding: 'utf8', 33 | }); 34 | 35 | const configUnknown: unknown = JSON.parse(configJSON); 36 | const configData = Config.parse(configUnknown); 37 | 38 | const { workspaces, activeWorkspace } = configData; 39 | 40 | this.configData = configData; 41 | const currentUser = workspaces[activeWorkspace].user; 42 | 43 | this.linear = new Linear({ 44 | apiKey: workspaces[activeWorkspace].apiKey, 45 | currentUser, 46 | }); 47 | this.cache = new Cache({ 48 | config: this.config, 49 | configData, 50 | linear: this.linear, 51 | }); 52 | this.user = currentUser; 53 | this.currentWorkspace = workspaces[activeWorkspace]; 54 | 55 | global.log = console.log; 56 | global.user = this.user; 57 | global.currentWorkspace = this.currentWorkspace; 58 | } catch (error) { 59 | /* Config folder doesn't exist */ 60 | if (error.code === 'ENOENT') { 61 | this.warnToInit(); 62 | this.exit(); 63 | } 64 | 65 | /* Invalid JSON in config file */ 66 | if (String(error).includes('SyntaxError')) { 67 | this.error(`Invalid json in config file \n${error}`); 68 | } 69 | 70 | /* Invalid config file */ 71 | this.error(error); 72 | } 73 | } 74 | 75 | warnToInit() { 76 | this.log(`No config found`); 77 | this.log(`\nLooks like you haven't initialized the cli yet!`); 78 | 79 | this.log(`You need to run ${chalk.blue('lr init')} first to setup your api key.`); 80 | } 81 | 82 | success(msg: string) { 83 | this.log(`${chalk.green('✓')} ${msg}`); 84 | } 85 | } 86 | 87 | export { Flags }; 88 | -------------------------------------------------------------------------------- /src/commands/issue/stop.ts: -------------------------------------------------------------------------------- 1 | import * as inquirer from 'inquirer'; 2 | import ora from 'ora'; 3 | import { Flags } from '@oclif/core'; 4 | import { IssueUpdateInput } from '../../generated/_documents'; 5 | import chalk from 'chalk'; 6 | import Command from '../../base'; 7 | import { render } from '../../components'; 8 | import { issueArgs, getIssueId, IssueArgs } from '../../utils/issueId'; 9 | 10 | export default class IssueStop extends Command { 11 | static description = 'Return issue to preview state'; 12 | 13 | static aliases = ['stop']; 14 | 15 | static flags = { 16 | unassign: Flags.boolean({ char: 'u', description: 'Unassign issue from yourself' }), 17 | }; 18 | 19 | static args = issueArgs; 20 | 21 | async run() { 22 | const { args, flags } = await this.parse(IssueStop); 23 | 24 | const issueId = getIssueId(args); 25 | 26 | const issue = await this.linear.query.issue(issueId, { historyCount: 20 }); 27 | 28 | if (issue.assignee?.id !== this.user.id) { 29 | const currentAssigneeMessage = issue.assignee?.id 30 | ? `Current assignee is ${chalk.blue(issue.assignee.displayName)}` 31 | : 'Currently nobody is assigned'; 32 | 33 | return this.warn( 34 | `Issue ${render.IssueId( 35 | issue.identifier 36 | )} cannot be stopped because it is not assigned to you.\n${currentAssigneeMessage}.` 37 | ); 38 | } 39 | 40 | if (issue.state.type !== 'started') { 41 | return this.warn( 42 | `Issue ${render.IssueId( 43 | issue.identifier 44 | )} is not in a started status.\nCurrent status: ${render.Status(issue.state)}` 45 | ); 46 | } 47 | 48 | const { nextStateId } = await inquirer.prompt<{ nextStateId: string }>([ 49 | { 50 | name: 'nextStateId', 51 | message: 'Select which status', 52 | type: 'list', 53 | choices: issue.team.states.nodes 54 | .filter((state) => state.type === 'unstarted') 55 | .map((state) => ({ name: render.Status(state), value: state.id })), 56 | }, 57 | ]); 58 | 59 | const unassign: IssueUpdateInput = flags.unassign ? { assigneeId: null } : {}; 60 | 61 | const spinner = ora('Updating issue').start(); 62 | 63 | await this.linear.issueUpdate(issue.id, { stateId: nextStateId, ...unassign }); 64 | 65 | const nextState = issue.team.states.nodes.find((state) => state.id === nextStateId); 66 | 67 | spinner.stop(); 68 | 69 | this.log(''); 70 | this.success( 71 | `The state of issue ${render.IssueId( 72 | issue.identifier 73 | )} now has status ${render.Status(nextState!)}${ 74 | flags.unassign ? ' and is no longer assigned to you' : '' 75 | }.` 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@egcli/lr", 3 | "description": "", 4 | "version": "0.18.0", 5 | "author": "Evan Godon @egodon", 6 | "bin": { 7 | "linear": "bin/run", 8 | "lr": "bin/run" 9 | }, 10 | "scripts": { 11 | "postpack": "rm -f oclif.manifest.json", 12 | "posttest": "eslint . --ext .ts --config .eslintrc", 13 | "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme", 14 | "version": "oclif-dev readme && git add README.md", 15 | "generate": "graphql-codegen --config codegen.yml" 16 | }, 17 | "bugs": "https://github.com/egodon/linear-cli/issues", 18 | "dependencies": { 19 | "@linear/sdk": "^1.8.4", 20 | "@oclif/core": "^1.1.2", 21 | "ansi-escapes": "^5.0.0", 22 | "boxen": "5.1.2", 23 | "chalk": "4.1.2", 24 | "cli-ux": "^6.0.8", 25 | "clipboardy": "2.3.0", 26 | "dayjs": "^1.10.4", 27 | "graphql": "^16.2.0", 28 | "inquirer": "^8.0.0", 29 | "inquirer-autocomplete-prompt": "^1.3.0", 30 | "marked": "^2.0.0", 31 | "marked-terminal": "4.1.1", 32 | "ora": "5.4.1", 33 | "string-width": "4.2.2", 34 | "terminal-link": "2.1.1", 35 | "tslib": "^2.2.0", 36 | "wrap-ansi": "7.0.0", 37 | "zod": "3.11.6" 38 | }, 39 | "devDependencies": { 40 | "@graphql-codegen/cli": "2.3.1", 41 | "@graphql-codegen/typescript": "2.4.2", 42 | "@graphql-codegen/typescript-document-nodes": "^2.2.2", 43 | "@graphql-codegen/typescript-operations": "^2.2.2", 44 | "@oclif/dev-cli": "^1", 45 | "@types/inquirer": "^8.1.3", 46 | "@types/marked": "^2.0.0", 47 | "@types/node": "^17.0.8", 48 | "eslint": "^8.6.0", 49 | "eslint-config-oclif": "^4.0.0", 50 | "eslint-config-oclif-typescript": "^1.0.2", 51 | "eslint-config-prettier": "^8.1.0", 52 | "eslint-plugin-import": "^2.22.1", 53 | "ts-node": "^10.4.0", 54 | "tsconfig-paths": "^3.9.0", 55 | "typescript": "^4.5.4" 56 | }, 57 | "engines": { 58 | "node": ">=14.0.0" 59 | }, 60 | "files": [ 61 | "/bin", 62 | "/lib", 63 | "/npm-shrinkwrap.json", 64 | "oclif.manifest.json" 65 | ], 66 | "homepage": "https://github.com/egodon/linear-cli", 67 | "keywords": [ 68 | "oclif" 69 | ], 70 | "license": "MIT", 71 | "main": "lib/index.js", 72 | "oclif": { 73 | "dirname": "lr", 74 | "commands": "./lib/commands", 75 | "bin": "lr", 76 | "topics": { 77 | "config": { 78 | "description": "View and delete config file" 79 | }, 80 | "issue": { 81 | "description": "Create, update and view issues" 82 | }, 83 | "workspace": { 84 | "description": "Add or switch to a new workspace" 85 | } 86 | } 87 | }, 88 | "repository": "evangodon/linear-cli", 89 | "types": "lib/index.d.ts" 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/linear/issue.ts: -------------------------------------------------------------------------------- 1 | import { LinearGraphQLClient } from '@linear/sdk'; 2 | import ora from 'ora'; 3 | import { GetIssueQuery, GetIssueQueryVariables } from 'generated/_documents'; 4 | import { handleError } from '../handleError'; 5 | 6 | const gql = String.raw; 7 | 8 | const issueQuery = gql` 9 | query getIssue($id: String!, $withComments: Boolean!, $historyCount: Int!) { 10 | issue(id: $id) { 11 | history(first: $historyCount) { 12 | nodes { 13 | actor { 14 | displayName 15 | } 16 | createdAt 17 | fromState { 18 | id 19 | name 20 | } 21 | } 22 | } 23 | archivedAt 24 | comments @include(if: $withComments) { 25 | nodes { 26 | user { 27 | displayName 28 | } 29 | body 30 | createdAt 31 | } 32 | } 33 | trashed 34 | url 35 | identifier 36 | createdAt 37 | project { 38 | name 39 | } 40 | creator { 41 | id 42 | displayName 43 | } 44 | priorityLabel 45 | previousIdentifiers 46 | branchName 47 | cycle { 48 | id 49 | } 50 | estimate 51 | description 52 | title 53 | number 54 | labels { 55 | nodes { 56 | name 57 | color 58 | } 59 | } 60 | parent { 61 | id 62 | } 63 | priority 64 | project { 65 | id 66 | } 67 | team { 68 | id 69 | name 70 | states { 71 | nodes { 72 | id 73 | name 74 | type 75 | color 76 | position 77 | } 78 | } 79 | } 80 | id 81 | assignee { 82 | id 83 | name 84 | displayName 85 | } 86 | state { 87 | id 88 | name 89 | type 90 | color 91 | } 92 | } 93 | } 94 | `; 95 | 96 | type IssueQueryOptions = { 97 | withComments?: boolean; 98 | historyCount?: number; 99 | }; 100 | 101 | /** Get one specific issue */ 102 | export const issue = (client: LinearGraphQLClient) => { 103 | return async ( 104 | issueId: string, 105 | { withComments = false, historyCount = 1 }: IssueQueryOptions = {} 106 | ) => { 107 | const spinner = ora().start(); 108 | 109 | const { data } = await client 110 | .rawRequest(issueQuery, { 111 | id: issueId, 112 | withComments, 113 | historyCount, 114 | }) 115 | .catch(handleError) 116 | .finally(() => spinner.stop()); 117 | 118 | if (!data || !data.issue) { 119 | throw new Error('No data returned from Linear'); 120 | } 121 | 122 | return data.issue; 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /src/commands/issue/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | import boxen, { Options } from 'boxen'; 4 | import { cli } from 'cli-ux'; 5 | import chalk from 'chalk'; 6 | import Command, { Flags } from '../../base'; 7 | import { GetIssueQuery } from '../../generated/_documents'; 8 | import { render } from '../../components'; 9 | import { issueArgs, getIssueId, IssueArgs } from '../../utils/issueId'; 10 | 11 | dayjs.extend(relativeTime); 12 | 13 | type Issue = GetIssueQuery['issue']; 14 | 15 | const boxenOptions: Options = { padding: 1, borderStyle: 'round' }; 16 | 17 | export default class IssueIndex extends Command { 18 | static description = 'Show issue info'; 19 | 20 | static aliases = ['i']; 21 | 22 | static args = issueArgs; 23 | 24 | static examples = [ 25 | '$ lr issue LIN-14', 26 | '$ lr issue LIN 14', 27 | '$ lr issue 14 (looks in default team)', 28 | ]; 29 | 30 | static flags = { 31 | description: Flags.boolean({ char: 'd', description: 'Show issue description' }), 32 | comments: Flags.boolean({ char: 'c', description: 'Show issue comments' }), 33 | open: Flags.boolean({ char: 'o', description: 'Open issue in web browser' }), 34 | }; 35 | 36 | renderIssueComments(issue: Issue) { 37 | if (issue.comments?.nodes.length === 0) { 38 | this.log(`Issue ${issue.identifier} does not have any comments`); 39 | } 40 | 41 | const dim = chalk.dim; 42 | 43 | for (const comment of issue.comments!.nodes.reverse()) { 44 | const author = comment.user.displayName; 45 | const markdown = render 46 | .Markdown(`${comment.body}`) 47 | .replace(/\n\n$/, '') 48 | .padEnd(author.length + 6); 49 | 50 | const authorLabel = ` ${comment.user.displayName} `; 51 | let commentBox = boxen(markdown, boxenOptions); 52 | 53 | const lengthOfBox = commentBox.match(/╭.*╮/)![0].length; 54 | 55 | commentBox = commentBox.replace( 56 | /╭.*╮/, 57 | `╭─${authorLabel.padEnd(lengthOfBox - 4, '─')}─╮` 58 | ); 59 | 60 | const createdAt = dim(dayjs(comment.createdAt).fromNow()); 61 | 62 | this.log(''); 63 | this.log(`${commentBox}\n ${createdAt}`); 64 | } 65 | } 66 | 67 | renderIssueDescription(issue: Issue) { 68 | const markdown = `${issue.identifier}\n # ${issue.title}\n${issue.description ?? ''}`; 69 | this.log(''); 70 | this.log(boxen(render.Markdown(markdown), boxenOptions)); 71 | } 72 | 73 | async run() { 74 | const { flags, args } = await this.parse(IssueIndex); 75 | 76 | const issueId = getIssueId(args); 77 | const issue = await this.linear.query.issue(issueId, { 78 | withComments: flags.comments, 79 | }); 80 | 81 | if (flags.open) { 82 | cli.open(issue.url); 83 | return; 84 | } 85 | 86 | if (flags.comments) { 87 | this.renderIssueComments(issue); 88 | return; 89 | } 90 | 91 | if (flags.description) { 92 | this.renderIssueDescription(issue); 93 | return; 94 | } 95 | 96 | render.IssueCard(issue); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/IssueCard.ts: -------------------------------------------------------------------------------- 1 | import boxen from 'boxen'; 2 | import chalk from 'chalk'; 3 | import dayjs from 'dayjs'; 4 | import relativeTime from 'dayjs/plugin/relativeTime'; 5 | import wrapAnsi from 'wrap-ansi'; 6 | import { Status } from './Status'; 7 | import { Priority } from './Priority'; 8 | import { Label } from './Label'; 9 | 10 | import type { GetIssueQuery } from '../generated/_documents'; 11 | 12 | dayjs.extend(relativeTime); 13 | 14 | type Issue = GetIssueQuery['issue']; 15 | 16 | export const IssueCard = (issue: Issue) => { 17 | const labelWidth = 12; 18 | 19 | const assignee = 20 | issue.assignee?.id === global.user.id 21 | ? `${issue.assignee.displayName} (You)` 22 | : issue.assignee?.displayName; 23 | 24 | const issueProperties: { label?: string; value: string }[] = [ 25 | { 26 | value: chalk.magenta.bold(issue.identifier), 27 | }, 28 | { 29 | value: wrapAnsi(issue.title, 60) + '\n', 30 | }, 31 | { 32 | label: 'Team', 33 | value: issue.team.name, 34 | }, 35 | { 36 | label: 'Status', 37 | value: Status(issue.state), 38 | }, 39 | { 40 | label: 'Priority', 41 | value: Priority(issue.priority), 42 | }, 43 | { 44 | label: 'Assignee', 45 | value: assignee ?? 'Unassigned', 46 | }, 47 | { 48 | label: 'Project', 49 | value: issue.project ? issue.project.name : '', 50 | }, 51 | { 52 | label: 'Labels', 53 | value: issue.labels.nodes 54 | .map( 55 | (label, idx) => 56 | `${Label(label)}${(idx + 1) % 3 === 0 ? '\n'.padEnd(labelWidth) : ''}` 57 | ) 58 | .join(' '), 59 | }, 60 | ]; 61 | 62 | const dim = chalk.dim; 63 | const reset = chalk.reset; 64 | 65 | const creator = issue.creator?.displayName; 66 | const createdAt = dayjs(issue.createdAt).fromNow(); 67 | 68 | let updatedBy; 69 | let updatedAt; 70 | const hasBeenUpdated = issue.history.nodes[0]; 71 | 72 | if (hasBeenUpdated) { 73 | updatedBy = issue.history.nodes[0].actor?.displayName; 74 | updatedAt = dayjs(issue.history.nodes[0].createdAt).fromNow(); 75 | } 76 | 77 | const displayCreator = creator ? ` by ${reset(creator)}` : ''; 78 | const displayUpdateAuthor = updatedBy ? ` by ${reset(updatedBy)}` : ''; 79 | 80 | const issueRender = issueProperties 81 | .map( 82 | (p) => 83 | (p.label && p.value ? dim(`${p.label}:`.padEnd(labelWidth)) : '') + 84 | (p.value ? p.value : '') 85 | ) 86 | .filter(Boolean) 87 | .join('\n') 88 | .concat(dim('\n\n---\n')) 89 | .concat(dim(`\n${'Created:'.padEnd(labelWidth)}${createdAt}${displayCreator}`)) 90 | .concat( 91 | hasBeenUpdated 92 | ? dim(`\n${'Updated:'.padEnd(labelWidth)}${updatedAt}${displayUpdateAuthor}`) 93 | : '' 94 | ) 95 | .concat( 96 | issue.archivedAt 97 | ? dim(`\n${'Archived:'.padEnd(labelWidth)}${dayjs(issue.archivedAt).fromNow()}`) 98 | : '' 99 | ); 100 | 101 | global.log(''); 102 | global.log(boxen(issueRender, { padding: 1, borderStyle: 'round' })); 103 | }; 104 | -------------------------------------------------------------------------------- /src/commands/issue/list.ts: -------------------------------------------------------------------------------- 1 | import { OutputFlags } from '@oclif/parser/lib'; 2 | import chalk from 'chalk'; 3 | import { cli } from 'cli-ux'; 4 | import Command, { Flags } from '../../base'; 5 | import { render } from '../../components'; 6 | 7 | export const tableFlags = { 8 | ...cli.table.flags(), 9 | sort: Flags.string({ 10 | description: "property to sort by (prepend '-' for descending)", 11 | default: '-status', 12 | }), 13 | columns: Flags.string({ 14 | exclusive: ['extended'], 15 | description: 'only show provided columns (comma-separated)', 16 | }), 17 | }; 18 | 19 | export default class IssueList extends Command { 20 | static description = 'List issues'; 21 | 22 | static aliases = ['list', 'ls', 'l']; 23 | 24 | static flags = { 25 | ...tableFlags, 26 | mine: Flags.boolean({ char: 'm', description: 'Only show issues assigned to me' }), 27 | team: Flags.string({ 28 | char: 't', 29 | description: 'List issues from another team', 30 | exclusive: ['all'], 31 | }), 32 | status: Flags.string({ 33 | char: 's', 34 | description: 'Only list issues with provided status', 35 | exclusive: ['all'], 36 | }), 37 | all: Flags.boolean({ char: 'a', description: 'List issues from all teams' }), 38 | uncompleted: Flags.boolean({ 39 | char: 'u', 40 | description: 'Only show uncompleted issues', 41 | exclusive: ['status'], 42 | }), 43 | }; 44 | 45 | async listAllTeamIssues() { 46 | const { flags } = await this.parse(IssueList); 47 | const issues = await this.linear.query.issuesAllTeams(); 48 | 49 | render.IssuesTable(issues, { flags }); 50 | } 51 | 52 | async listMyIssues() { 53 | const { flags } = await this.parse(IssueList); 54 | const issues = await this.linear.query.assignedIssues(); 55 | 56 | render.IssuesTable(issues, { flags }); 57 | } 58 | 59 | async listTeamIssues() { 60 | const { flags } = await this.parse(IssueList); 61 | const teamId = flags.team ?? global.currentWorkspace.defaultTeam; 62 | const issues = await this.linear.query.issuesFromTeam({ 63 | teamId, 64 | first: 10, 65 | }); 66 | 67 | render.IssuesTable(issues, { 68 | flags: { 69 | ...flags, 70 | team: teamId, 71 | }, 72 | }); 73 | } 74 | 75 | async listIssuesWithStatus() { 76 | const { flags } = await this.parse(IssueList); 77 | const cache = await this.cache.read(); 78 | 79 | const teamId = flags.team ?? global.currentWorkspace.defaultTeam; 80 | const team = cache.teams[teamId.toUpperCase()]; 81 | 82 | if (!team) { 83 | this.log(`Did not find team with key "${teamId}"`); 84 | this.log(`Teams found in cache:\n-`, Object.keys(cache.teams).join('\n- ')); 85 | this.log(`You can try refreshing the cache with ${chalk.blue('lr cache:refresh')}`); 86 | return; 87 | } 88 | 89 | const match = team.states.find((state) => 90 | state.name.toLowerCase().includes(String(flags.status).toLowerCase()) 91 | ); 92 | 93 | if (!match) { 94 | this.log(`Did not find any status with string "${flags.status}"\n`); 95 | this.log( 96 | `Statuses for team ${teamId} found in cache:\n-`, 97 | team.states.map((state) => state.name).join('\n- ') 98 | ); 99 | this.log(`You can try refreshing the cache with ${chalk.blue('lr cache:refresh')}`); 100 | return; 101 | } 102 | 103 | const issues = await this.linear.query.issuesWithStatus(match?.id); 104 | 105 | render.IssuesTable(issues, { 106 | flags: { 107 | ...flags, 108 | team: teamId, 109 | }, 110 | }); 111 | } 112 | 113 | async run() { 114 | const { flags } = await this.parse(IssueList); 115 | 116 | if (flags.status) { 117 | this.listIssuesWithStatus(); 118 | return; 119 | } 120 | 121 | if (flags.mine) { 122 | await this.listMyIssues(); 123 | return; 124 | } 125 | 126 | if (flags.all) { 127 | this.listAllTeamIssues(); 128 | return; 129 | } 130 | 131 | this.listTeamIssues(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { LinearClient } from '@linear/sdk'; 4 | import boxen from 'boxen'; 5 | import ora from 'ora'; 6 | import chalk from 'chalk'; 7 | import * as inquirer from 'inquirer'; 8 | import { Command } from '@oclif/core'; 9 | import { Config, User } from '../lib/configSchema'; 10 | import CacheRefresh from './cache/refresh'; 11 | 12 | type PromptResponse = { 13 | apiKey: string; 14 | label: string; 15 | }; 16 | 17 | type RequiredConfigData = { 18 | apiKey: string; 19 | workspaceLabel: string; 20 | user: User; 21 | defaultTeam: string; 22 | }; 23 | 24 | /** 25 | * Write Linear api key and user info to config file 26 | * 27 | * @TODO: check if config file exists before running 28 | */ 29 | export default class Init extends Command { 30 | static description = 'Setup the Linear cli'; 31 | 32 | configFilePath = `${this.config.configDir}/config.json`; 33 | 34 | async promptForKey(): Promise { 35 | return inquirer.prompt([ 36 | { 37 | name: 'apiKey', 38 | message: 'Paste your Linear api key here:', 39 | }, 40 | { 41 | name: 'label', 42 | message: 'Create a label for this key (e.g. "Work")', 43 | }, 44 | ]); 45 | } 46 | 47 | async getWorkspaceInfo(apiKey: string): Promise<{ user: User; defaultTeam: string }> { 48 | const linearClient = new LinearClient({ apiKey }); 49 | let user; 50 | 51 | const spinner = ora('Getting your user info').start(); 52 | 53 | try { 54 | user = await linearClient.viewer; 55 | } catch (error) { 56 | this.error('Invalid api key'); 57 | } 58 | 59 | /* If no user, probably means key is invalid */ 60 | if (!user) { 61 | this.error('Invalid api key'); 62 | } 63 | 64 | const teamConnection = await linearClient.teams(); 65 | 66 | if (!teamConnection) { 67 | this.error('Failed to get your teams'); 68 | } 69 | 70 | const teams = teamConnection.nodes?.map((team) => ({ 71 | id: team.id, 72 | name: team.name, 73 | value: team.key, 74 | })); 75 | 76 | spinner.stop(); 77 | 78 | const { defaultTeam } = await inquirer.prompt<{ defaultTeam: string }>([ 79 | { 80 | name: 'defaultTeam', 81 | message: 'Select your default team', 82 | type: 'list', 83 | choices: teams, 84 | }, 85 | ]); 86 | 87 | return { 88 | user: { 89 | id: user.id!, 90 | name: user.name!, 91 | email: user.email!, 92 | }, 93 | defaultTeam, 94 | }; 95 | } 96 | 97 | async writeConfigFile({ 98 | apiKey, 99 | workspaceLabel, 100 | user, 101 | defaultTeam, 102 | }: RequiredConfigData) { 103 | const { configDir } = this.config; 104 | try { 105 | if (!fs.existsSync(configDir)) { 106 | fs.mkdirSync(configDir); 107 | } 108 | 109 | const config: Config = { 110 | activeWorkspace: workspaceLabel, 111 | workspaces: { 112 | [workspaceLabel]: { 113 | apiKey, 114 | defaultTeam, 115 | user, 116 | }, 117 | }, 118 | }; 119 | 120 | await fs.promises.writeFile(this.configFilePath, JSON.stringify(config, null, 2), { 121 | flag: 'w', 122 | }); 123 | 124 | this.log(''); 125 | this.log(`Wrote api key and user info to ${this.configFilePath}`); 126 | this.log(''); 127 | } catch (error) { 128 | this.error(error); 129 | } 130 | } 131 | 132 | async run() { 133 | this.log(''); 134 | this.log(`You'll need to create a personal Linear api key.`); 135 | this.log( 136 | `You can create one here ${chalk.magenta( 137 | 'https://linear.app/joinlane/settings/api' 138 | )}.` 139 | ); 140 | 141 | const response = await this.promptForKey(); 142 | 143 | const { user, defaultTeam } = await this.getWorkspaceInfo(response.apiKey); 144 | 145 | await this.writeConfigFile({ 146 | apiKey: response.apiKey, 147 | workspaceLabel: response.label, 148 | user, 149 | defaultTeam, 150 | }); 151 | 152 | await CacheRefresh.run(); 153 | 154 | this.log( 155 | boxen(`${chalk.green('✓')} Linear CLI setup successful!`, { 156 | padding: 1, 157 | borderStyle: 'round', 158 | }) 159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/commands/issue/update.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | import * as inquirer from 'inquirer'; 4 | import ora from 'ora'; 5 | import Command from '../../base'; 6 | import { render } from '../../components'; 7 | import { issueArgs, getIssueId, IssueArgs } from '../../utils/issueId'; 8 | 9 | const properties = ['title', 'description', 'status'] as const; 10 | type Property = typeof properties[number]; 11 | 12 | type Args = { issueId: string } & { propertyToModify: Property }; 13 | 14 | export default class IssueUpdate extends Command { 15 | static description = 'Update an issue'; 16 | 17 | static aliases = ['update', 'u']; 18 | 19 | static flags = { 20 | property: Flags.string({ 21 | char: 'p', 22 | description: 'Property to modify', 23 | options: ['title', 'description', 'status'], 24 | }), 25 | }; 26 | 27 | static args = issueArgs; 28 | 29 | runUpdateMethod(issueId: string, property: Property) { 30 | function throwBadProperty(property: never): never { 31 | throw new Error(`Update operation for ${property} not implemented yet`); 32 | } 33 | 34 | switch (property) { 35 | case 'title': 36 | this.updateTitle(issueId); 37 | return; 38 | 39 | case 'status': 40 | return this.updateStatus(issueId); 41 | 42 | case 'description': 43 | return this.updateDescription(issueId); 44 | 45 | default: 46 | throwBadProperty(property); 47 | } 48 | } 49 | 50 | async promptForProperty(issueId: string) { 51 | const { property } = await inquirer.prompt<{ property: Property }>([ 52 | { 53 | name: 'property', 54 | message: `What do you want to update`, 55 | type: 'list', 56 | choices: properties.map((property) => ({ 57 | name: property[0].toUpperCase() + property.slice(1), 58 | value: property, 59 | })), 60 | }, 61 | ]); 62 | 63 | this.runUpdateMethod(issueId, property); 64 | } 65 | 66 | /** 67 | * Update issue title 68 | */ 69 | async updateTitle(issueId: string) { 70 | const { title } = await inquirer.prompt<{ title: string }>([ 71 | { 72 | name: 'title', 73 | message: `New Title`, 74 | type: 'input', 75 | }, 76 | ]); 77 | 78 | await this.linear.issueUpdate(issueId, { title }); 79 | 80 | this.log(''); 81 | this.log(`Issue ${chalk.magenta(issueId)} has been updated with title \`${title}\``); 82 | } 83 | 84 | /** 85 | * Update issue description 86 | */ 87 | async updateDescription(issueId: string) { 88 | const issue = await this.linear.query.issue(issueId); 89 | 90 | const { description } = await inquirer.prompt<{ description: string }>([ 91 | { 92 | name: 'description', 93 | message: `New Description`, 94 | type: 'editor', 95 | default: issue.description, 96 | postfix: 'md', 97 | }, 98 | ]); 99 | 100 | await this.linear.issueUpdate(issueId, { description }); 101 | this.log(''); 102 | this.log(`The description of issue ${issue.identifier} has been updated`); 103 | } 104 | 105 | /** 106 | * Update issue status 107 | */ 108 | async updateStatus(issueId: string) { 109 | const spinner = ora().start(); 110 | const issue = await this.linear.query.issueWorkflowStates(issueId); 111 | 112 | const workflowStates = issue.team.states.nodes; 113 | 114 | spinner.stop(); 115 | 116 | const { stateName } = await inquirer.prompt<{ stateName: typeof workflowStates[0] }>([ 117 | { 118 | name: 'stateName', 119 | message: `Choose a state (current: ${render.Status(issue.state)})`, 120 | type: 'list', 121 | choices: workflowStates 122 | .filter((state) => state.id !== issue.state.id) 123 | .map((state) => ({ 124 | name: render.Status(state), 125 | value: state, 126 | })), 127 | pageSize: 10, 128 | }, 129 | ]); 130 | 131 | const newState = workflowStates.find((state) => state.id === stateName.id); 132 | 133 | if (!newState) { 134 | this.error('Did not find that state.'); 135 | } 136 | 137 | await this.linear.issueUpdate(issueId, { stateId: newState.id }); 138 | 139 | this.log(''); 140 | this.success( 141 | `Updated status of issue ${chalk.magenta(issue.identifier)} to ${newState.name}` 142 | ); 143 | } 144 | 145 | async run() { 146 | const { args, flags } = await await this.parse(IssueUpdate); 147 | 148 | const issueId = getIssueId(args); 149 | 150 | if (!flags.property) { 151 | await this.promptForProperty(issueId); 152 | return; 153 | } 154 | 155 | this.runUpdateMethod(issueId, flags.property as Property); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/components/IssuesTable.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { cli } from 'cli-ux'; 3 | import sw from 'string-width'; 4 | import { IssueFragment } from '../generated/_documents'; 5 | import { Status } from './Status'; 6 | import { Label } from './Label'; 7 | import { Priority } from './Priority'; 8 | 9 | type Options = { 10 | flags: any; 11 | }; 12 | 13 | export type TableIssue = Pick< 14 | IssueFragment, 15 | 'identifier' | 'title' | 'state' | 'assignee' | 'priority' | 'team' | 'labels' 16 | >; 17 | 18 | /** 19 | */ 20 | export const IssuesTable = (issues: TableIssue[], { flags }: Options) => { 21 | const { log } = global; 22 | let rowIndex = 0; 23 | 24 | /* Colorize header with custom logger since cli-ux doesn't support it. */ 25 | function printTable(row: string) { 26 | if (!flags['no-header'] && rowIndex < 2) { 27 | const headerColor = chalk.magenta; 28 | log(headerColor(row)); 29 | rowIndex++; 30 | return; 31 | } 32 | 33 | log(row); 34 | rowIndex++; 35 | } 36 | 37 | /* Filters */ 38 | issues = 39 | flags.mine && flags.team 40 | ? issues.filter((issue) => issue.team.key === flags.team.toUpperCase()) 41 | : issues; 42 | 43 | issues = 44 | flags.mine && flags.status 45 | ? issues.filter((issue) => issue.assignee?.id === global.user.id) 46 | : issues; 47 | 48 | issues = flags.uncompleted 49 | ? issues.filter( 50 | (issue) => issue.state.type !== 'completed' && issue.state.type !== 'canceled' 51 | ) 52 | : issues; 53 | 54 | const MAX_TITLE_LENGTH = 80; 55 | issues = issues.map((issue) => ({ 56 | ...issue, 57 | title: 58 | issue.title.length > MAX_TITLE_LENGTH 59 | ? `${issue.title.slice(0, MAX_TITLE_LENGTH)}...` 60 | : issue.title, 61 | })); 62 | 63 | /* Get longest string length for each column */ 64 | const longestLengthOf = { 65 | identifier: 0, 66 | title: 0, 67 | state: 0, 68 | assignee: 0, 69 | }; 70 | 71 | for (const issue of issues) { 72 | const { identifier, title, state, assignee } = longestLengthOf; 73 | longestLengthOf.identifier = 74 | identifier < issue.identifier.length ? issue.identifier.length : identifier; 75 | 76 | longestLengthOf.title = title < issue.title.length ? issue.title.length : title; 77 | 78 | longestLengthOf.state = 79 | state < issue.state.name.length ? issue.state.name.length : state; 80 | 81 | longestLengthOf.assignee = 82 | issue.assignee && assignee < issue.assignee.displayName.length 83 | ? issue.assignee.displayName.length 84 | : assignee; 85 | } 86 | 87 | const team = 88 | flags.all || (flags.mine && !flags.team) ? 'All' : String(flags.team).toUpperCase(); 89 | 90 | if (issues.length === 0) { 91 | log('No issues to show'); 92 | process.exit(); 93 | } 94 | 95 | const optionsHeader = [ 96 | `Team: ${team}`, 97 | flags.status && `Status: ${Status(issues[0].state)}`, 98 | !flags.status && `Sort: ${flags.sort}`, 99 | flags.uncompleted && 'Uncompleted issues', 100 | ].filter(Boolean); 101 | 102 | /* Custom sorting */ 103 | if (flags.sort.includes('priority')) { 104 | issues = issues 105 | .sort((i1, i2) => i1.priority - i2.priority) 106 | .sort((_i1, i2) => (i2.priority === 0 ? -1 : 1)); 107 | 108 | if (flags.sort.startsWith('-')) { 109 | issues = issues.reverse(); 110 | } 111 | 112 | delete flags.sort; 113 | } 114 | 115 | try { 116 | log(chalk.dim(optionsHeader.join(' | '))); 117 | log(''); 118 | cli.table( 119 | issues, 120 | { 121 | identifier: { 122 | header: 'ID', 123 | minWidth: longestLengthOf.identifier + 2, 124 | get: (issue) => issue.identifier, 125 | }, 126 | state: { 127 | minWidth: longestLengthOf.state + 4, 128 | header: 'Status', 129 | get: (issue) => `${Status(issue.state)}`, 130 | }, 131 | title: { 132 | header: 'Title', 133 | minWidth: longestLengthOf.title + 4, 134 | get: (issue) => issue.title, 135 | }, 136 | assignee: { 137 | minWidth: Math.max(longestLengthOf.assignee + 4, 'assignee'.length + 4), 138 | header: 'Assignee', 139 | get: (issue) => issue.assignee?.displayName ?? chalk.dim('—'), 140 | extended: true, 141 | }, 142 | priority: { 143 | header: 'Priority', 144 | minWidth: 12, 145 | get: (issue) => Priority(issue.priority), 146 | extended: true, 147 | }, 148 | labels: { 149 | header: 'Labels', 150 | get: (issue) => issue.labels.nodes.map((label) => Label(label)).join(' '), 151 | extended: true, 152 | }, 153 | }, 154 | { 155 | printLine: printTable, 156 | ...flags, 157 | } 158 | ); 159 | } catch (error) { 160 | log(`${chalk.red('Error')}: ${error.message}`); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > **This CLI is unmaintained and should not be used** 3 | 4 | ## Linear CLI 5 | 6 | A CLI for [Linear](https://linear.app/) that allows you to quickly view, create and update issues. 7 | 8 | [![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) 9 | [![Version](https://img.shields.io/npm/v/@egcli/lr.svg)](https://npmjs.org/package/@egcli/lr) 10 | [![Downloads/week](https://img.shields.io/npm/dw/@egcli/lr.svg)](https://npmjs.org/package/@egcli/lr) 11 | [![License](https://img.shields.io/npm/l/linear-cli.svg)](https://github.com/egodon/linear-cli/blob/master/package.json) 12 | 13 | 14 | 15 | # Installation 16 | 17 | ###### Install with npm 18 | 19 | ``` 20 | $ npm install -g @egcli/lr 21 | ``` 22 | 23 | ###### Install with yarn 24 | 25 | ``` 26 | $ yarn global add @egcli/lr 27 | ``` 28 | 29 | ###### Setup API key 30 | 31 | ``` 32 | $ lr init 33 | ``` 34 | 35 | # Commands 36 | 37 | 38 | * [`lr cache:refresh`](#lr-cacherefresh) 39 | * [`lr cache:show`](#lr-cacheshow) 40 | * [`lr config:delete`](#lr-configdelete) 41 | * [`lr config:show`](#lr-configshow) 42 | * [`lr init`](#lr-init) 43 | * [`lr issue ISSUEID`](#lr-issue-issueid) 44 | * [`lr issue:create`](#lr-issuecreate) 45 | * [`lr issue:list`](#lr-issuelist) 46 | * [`lr issue:search [QUERY]`](#lr-issuesearch-query) 47 | * [`lr issue:start ISSUEID`](#lr-issuestart-issueid) 48 | * [`lr issue:stop ISSUEID`](#lr-issuestop-issueid) 49 | * [`lr issue:update ISSUEID`](#lr-issueupdate-issueid) 50 | * [`lr teams:show`](#lr-teamsshow) 51 | * [`lr workspace:add`](#lr-workspaceadd) 52 | * [`lr workspace:current`](#lr-workspacecurrent) 53 | * [`lr workspace:switch`](#lr-workspaceswitch) 54 | 55 | ## `lr cache:refresh` 56 | 57 | Refresh the cache 58 | 59 | ``` 60 | USAGE 61 | $ lr cache:refresh 62 | ``` 63 | 64 | _See code: [src/commands/cache/refresh.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/cache/refresh.ts)_ 65 | 66 | ## `lr cache:show` 67 | 68 | Print the cache file 69 | 70 | ``` 71 | USAGE 72 | $ lr cache:show 73 | 74 | OPTIONS 75 | -p, --pretty Pretty print 76 | ``` 77 | 78 | _See code: [src/commands/cache/show.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/cache/show.ts)_ 79 | 80 | ## `lr config:delete` 81 | 82 | ``` 83 | USAGE 84 | $ lr config:delete 85 | ``` 86 | 87 | _See code: [src/commands/config/delete.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/config/delete.ts)_ 88 | 89 | ## `lr config:show` 90 | 91 | ``` 92 | USAGE 93 | $ lr config:show 94 | ``` 95 | 96 | _See code: [src/commands/config/show.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/config/show.ts)_ 97 | 98 | ## `lr init` 99 | 100 | Setup the Linear cli 101 | 102 | ``` 103 | USAGE 104 | $ lr init 105 | ``` 106 | 107 | _See code: [src/commands/init.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/init.ts)_ 108 | 109 | ## `lr issue ISSUEID` 110 | 111 | Show issue info 112 | 113 | ``` 114 | USAGE 115 | $ lr issue ISSUEID 116 | 117 | OPTIONS 118 | -c, --comments Show issue comments 119 | -d, --description Show issue description 120 | -o, --open Open issue in web browser 121 | 122 | ALIASES 123 | $ lr i 124 | 125 | EXAMPLES 126 | $ lr issue LIN-14 127 | $ lr issue LIN 14 128 | $ lr issue 14 (looks in default team) 129 | ``` 130 | 131 | _See code: [src/commands/issue/index.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/index.ts)_ 132 | 133 | ## `lr issue:create` 134 | 135 | Create a new issue 136 | 137 | ``` 138 | USAGE 139 | $ lr issue:create 140 | 141 | OPTIONS 142 | -c, --copy Copy issue url to clipboard after creating 143 | 144 | ALIASES 145 | $ lr create 146 | $ lr c 147 | ``` 148 | 149 | _See code: [src/commands/issue/create.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/create.ts)_ 150 | 151 | ## `lr issue:list` 152 | 153 | List issues 154 | 155 | ``` 156 | USAGE 157 | $ lr issue:list 158 | 159 | OPTIONS 160 | -a, --all List issues from all teams 161 | -m, --mine Only show issues assigned to me 162 | -s, --status=status Only list issues with provided status 163 | -t, --team=team List issues from another team 164 | -u, --uncompleted Only show uncompleted issues 165 | -x, --extended show extra columns 166 | --columns=columns only show provided columns (comma-separated) 167 | --csv output is csv format [alias: --output=csv] 168 | --filter=filter filter property by partial string matching, ex: name=foo 169 | --no-header hide table header from output 170 | --no-truncate do not truncate output to fit screen 171 | --output=csv|json|yaml output in a more machine friendly format 172 | --sort=sort [default: -status] property to sort by (prepend '-' for descending) 173 | 174 | ALIASES 175 | $ lr list 176 | $ lr ls 177 | $ lr l 178 | ``` 179 | 180 | _See code: [src/commands/issue/list.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/list.ts)_ 181 | 182 | ## `lr issue:search [QUERY]` 183 | 184 | describe the command here 185 | 186 | ``` 187 | USAGE 188 | $ lr issue:search [QUERY] 189 | 190 | ALIASES 191 | $ lr search 192 | $ lr s 193 | ``` 194 | 195 | _See code: [src/commands/issue/search.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/search.ts)_ 196 | 197 | ## `lr issue:start ISSUEID` 198 | 199 | Change status of issue to "In progress" and assign to yourself. 200 | 201 | ``` 202 | USAGE 203 | $ lr issue:start ISSUEID 204 | 205 | OPTIONS 206 | -c, --copy-branch copy git branch to clip-board 207 | 208 | ALIASES 209 | $ lr start 210 | $ lr s 211 | ``` 212 | 213 | _See code: [src/commands/issue/start.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/start.ts)_ 214 | 215 | ## `lr issue:stop ISSUEID` 216 | 217 | Return issue to preview state 218 | 219 | ``` 220 | USAGE 221 | $ lr issue:stop ISSUEID 222 | 223 | OPTIONS 224 | -u, --unassign Unassign issue from yourself 225 | 226 | ALIASES 227 | $ lr stop 228 | ``` 229 | 230 | _See code: [src/commands/issue/stop.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/stop.ts)_ 231 | 232 | ## `lr issue:update ISSUEID` 233 | 234 | Update an issue 235 | 236 | ``` 237 | USAGE 238 | $ lr issue:update ISSUEID 239 | 240 | OPTIONS 241 | -p, --property=title|description|status Property to modify 242 | 243 | ALIASES 244 | $ lr update 245 | $ lr u 246 | ``` 247 | 248 | _See code: [src/commands/issue/update.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/issue/update.ts)_ 249 | 250 | ## `lr teams:show` 251 | 252 | Show teams in this workspace 253 | 254 | ``` 255 | USAGE 256 | $ lr teams:show 257 | 258 | OPTIONS 259 | -m, --mine Pretty print 260 | ``` 261 | 262 | _See code: [src/commands/teams/show.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/teams/show.ts)_ 263 | 264 | ## `lr workspace:add` 265 | 266 | Add a new workplace 267 | 268 | ``` 269 | USAGE 270 | $ lr workspace:add 271 | ``` 272 | 273 | _See code: [src/commands/workspace/add.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/workspace/add.ts)_ 274 | 275 | ## `lr workspace:current` 276 | 277 | Print current workspace 278 | 279 | ``` 280 | USAGE 281 | $ lr workspace:current 282 | ``` 283 | 284 | _See code: [src/commands/workspace/current.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/workspace/current.ts)_ 285 | 286 | ## `lr workspace:switch` 287 | 288 | Switch to another workspace 289 | 290 | ``` 291 | USAGE 292 | $ lr workspace:switch 293 | ``` 294 | 295 | _See code: [src/commands/workspace/switch.ts](https://github.com/evangodon/linear-cli/blob/v0.17.0/src/commands/workspace/switch.ts)_ 296 | 297 | --------------------------------------------------------------------------------