mirror of
https://github.com/schpet/linear-cli.git
synced 2026-09-14 14:26:50 +08:00
2128 lines
60 KiB
TypeScript
2128 lines
60 KiB
TypeScript
import { Command, EnumType } from "@cliffy/command";
|
|
import { load } from "@std/dotenv";
|
|
import { getOption } from "./config.ts";
|
|
import { Checkbox, Input, prompt, Select } from "@cliffy/prompt";
|
|
|
|
// Try loading .env from current directory first, then from git root if not found
|
|
let envVars: Record<string, string> = {};
|
|
if (await Deno.stat(".env").catch(() => null)) {
|
|
envVars = await load();
|
|
} else {
|
|
try {
|
|
const gitRoot = new TextDecoder()
|
|
.decode(
|
|
await new Deno.Command("git", {
|
|
args: ["rev-parse", "--show-toplevel"],
|
|
})
|
|
.output()
|
|
.then((output) => output.stdout),
|
|
)
|
|
.trim();
|
|
|
|
const gitRootEnvPath = join(gitRoot, ".env");
|
|
if (await Deno.stat(gitRootEnvPath).catch(() => null)) {
|
|
envVars = await load({ envPath: gitRootEnvPath });
|
|
}
|
|
} catch {
|
|
// Silently continue if not in a git repo
|
|
}
|
|
}
|
|
|
|
// apply known environment variables from .env
|
|
const ALLOWED_ENV_VAR_PREFIXES = ["LINEAR_", "GH_", "GITHUB_"];
|
|
for (const [key, value] of Object.entries(envVars)) {
|
|
if (ALLOWED_ENV_VAR_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
|
// use same precedence as dotenv
|
|
// https://jsr.io/@std/dotenv/0.225.5/mod.ts#L221
|
|
if (Deno.env.get(key) !== undefined) continue;
|
|
Deno.env.set(key, value);
|
|
}
|
|
}
|
|
|
|
const SortType = new EnumType(["manual", "priority"]);
|
|
const StateType = new EnumType([
|
|
"triage",
|
|
"backlog",
|
|
"unstarted",
|
|
"started",
|
|
"completed",
|
|
"canceled",
|
|
]);
|
|
import { Spinner } from "@std/cli/unstable-spinner";
|
|
import { open } from "@opensrc/deno-open";
|
|
import { CompletionsCommand } from "@cliffy/command/completions";
|
|
import denoConfig from "./deno.json" with { type: "json" };
|
|
import { encodeBase64 } from "@std/encoding/base64";
|
|
import { renderMarkdown } from "@littletof/charmd";
|
|
import { basename, join } from "@std/path";
|
|
import { unicodeWidth } from "@std/cli";
|
|
import { gql } from "./__generated__/gql.ts";
|
|
|
|
import { GraphQLClient } from "graphql-request";
|
|
|
|
function padDisplay(s: string, width: number): string {
|
|
const w = unicodeWidth(s);
|
|
return s + " ".repeat(Math.max(0, width - w));
|
|
}
|
|
|
|
function stripConsoleFormat(s: string): string {
|
|
return s.replace(/%c/g, "");
|
|
}
|
|
|
|
function padDisplayFormatted(s: string, width: number): string {
|
|
const plain = stripConsoleFormat(s);
|
|
const w = unicodeWidth(plain);
|
|
return s + " ".repeat(Math.max(0, width - w));
|
|
}
|
|
|
|
function getTimeAgo(date: Date): string {
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffMins < 1) return "just now";
|
|
if (diffMins < 60) return `${diffMins} minutes ago`;
|
|
if (diffHours < 24) {
|
|
return `about ${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
|
|
}
|
|
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
|
}
|
|
|
|
async function getCurrentBranch(): Promise<string | null> {
|
|
const process = new Deno.Command("git", {
|
|
args: ["symbolic-ref", "--short", "HEAD"],
|
|
});
|
|
const { stdout } = await process.output();
|
|
const branch = new TextDecoder().decode(stdout).trim();
|
|
return branch || null;
|
|
}
|
|
|
|
async function getRepoDir(): Promise<string> {
|
|
const process = new Deno.Command("git", {
|
|
args: ["rev-parse", "--show-toplevel"],
|
|
});
|
|
const { stdout } = await process.output();
|
|
const fullPath = new TextDecoder().decode(stdout).trim();
|
|
return basename(fullPath);
|
|
}
|
|
|
|
async function branchExists(branch: string): Promise<boolean> {
|
|
try {
|
|
const process = new Deno.Command("git", {
|
|
args: ["rev-parse", "--verify", branch],
|
|
});
|
|
const { success } = await process.output();
|
|
return success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getEditor(): Promise<string | null> {
|
|
// Try git config first
|
|
try {
|
|
const process = new Deno.Command("git", {
|
|
args: ["config", "--global", "core.editor"],
|
|
});
|
|
const { stdout, success } = await process.output();
|
|
if (success) {
|
|
const editor = new TextDecoder().decode(stdout).trim();
|
|
if (editor) return editor;
|
|
}
|
|
} catch {
|
|
// Fall through to next option
|
|
}
|
|
|
|
// Try EDITOR environment variable
|
|
const editor = Deno.env.get("EDITOR");
|
|
if (editor) return editor;
|
|
|
|
return null;
|
|
}
|
|
|
|
async function openEditor(): Promise<string | undefined> {
|
|
const editor = await getEditor();
|
|
if (!editor) {
|
|
console.error(
|
|
"No editor found. Please set EDITOR environment variable or configure git editor with: git config --global core.editor <editor>",
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
// Create a temporary file
|
|
const tempFile = await Deno.makeTempFile({ suffix: ".md" });
|
|
|
|
try {
|
|
// Open the editor
|
|
const process = new Deno.Command(editor, {
|
|
args: [tempFile],
|
|
stdin: "inherit",
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
|
|
const { success } = await process.output();
|
|
|
|
if (!success) {
|
|
console.error("Editor exited with an error");
|
|
return undefined;
|
|
}
|
|
|
|
// Read the content back
|
|
const content = await Deno.readTextFile(tempFile);
|
|
const cleaned = content.trim();
|
|
|
|
return cleaned.length > 0 ? cleaned : undefined;
|
|
} catch (error) {
|
|
console.error(
|
|
"Failed to open editor:",
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
return undefined;
|
|
} finally {
|
|
// Clean up the temporary file
|
|
try {
|
|
await Deno.remove(tempFile);
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getWorkflowStates(
|
|
teamId: string,
|
|
): Promise<
|
|
Array<{ id: string; name: string; type: string; position: number }>
|
|
> {
|
|
const query = gql(`
|
|
query GetWorkflowStates($teamId: String!) {
|
|
team(id: $teamId) {
|
|
states {
|
|
nodes {
|
|
id
|
|
name
|
|
type
|
|
position
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const client = getGraphQLClient();
|
|
const result = await client.request(query, { teamId });
|
|
return result.team.states.nodes.sort((
|
|
a: { position: number },
|
|
b: { position: number },
|
|
) => a.position - b.position);
|
|
}
|
|
|
|
async function getStartedState(
|
|
teamId: string,
|
|
): Promise<{ id: string; name: string }> {
|
|
const states = await getWorkflowStates(teamId);
|
|
const startedStates = states.filter((s) => s.type === "started");
|
|
|
|
if (!startedStates.length) {
|
|
throw new Error("No 'started' state found in workflow");
|
|
}
|
|
|
|
return { id: startedStates[0].id, name: startedStates[0].name };
|
|
}
|
|
|
|
async function getWorkflowStateByNameOrType(
|
|
teamId: string,
|
|
nameOrType: string,
|
|
): Promise<{ id: string; name: string } | undefined> {
|
|
const states = await getWorkflowStates(teamId);
|
|
|
|
// First try exact name match
|
|
const nameMatch = states.find((s) =>
|
|
s.name.toLowerCase() === nameOrType.toLowerCase()
|
|
);
|
|
if (nameMatch) {
|
|
return { id: nameMatch.id, name: nameMatch.name };
|
|
}
|
|
|
|
// Then try type match
|
|
const typeMatch = states.find((s) => s.type === nameOrType.toLowerCase());
|
|
if (typeMatch) {
|
|
return { id: typeMatch.id, name: typeMatch.name };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function updateIssueState(
|
|
issueId: string,
|
|
stateId: string,
|
|
): Promise<void> {
|
|
const mutation = gql(`
|
|
mutation UpdateIssueState($issueId: String!, $stateId: String!) {
|
|
issueUpdate(
|
|
id: $issueId,
|
|
input: { stateId: $stateId }
|
|
) {
|
|
success
|
|
}
|
|
}
|
|
`);
|
|
|
|
const client = getGraphQLClient();
|
|
await client.request(mutation, { issueId, stateId });
|
|
}
|
|
|
|
function isValidLinearId(id: string): boolean {
|
|
return /^[A-Za-z0-9]+-[1-9][0-9]*$/.test(id);
|
|
}
|
|
|
|
async function getProjectUidByName(
|
|
name: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetProjectUidByName($name: String!) {
|
|
projects(filter: {name: {eq: $name}}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
return data.projects?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getProjectUidOptionsByName(
|
|
name: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetProjectUidOptionsByName($name: String!) {
|
|
projects(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
const qResults = data.projects?.nodes || [];
|
|
return Object.fromEntries(qResults.map((t) => [t.id, t.name]));
|
|
}
|
|
|
|
async function getIssueIdByTitle(
|
|
title: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueIdByTitle($title: String!) {
|
|
issues(filter: {title: {eq: $title}}) {nodes{identifier}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { title });
|
|
return data.issues?.nodes[0]?.identifier;
|
|
}
|
|
|
|
async function getIssueUidByIdentifier(
|
|
identifier: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueUidByIdentifier($identifier: String!) {
|
|
issue(id: $identifier) { id }
|
|
}
|
|
`);
|
|
const data = await client.request(query, { identifier });
|
|
return data.issue?.id;
|
|
}
|
|
|
|
async function getIssueUidOptionsByTitle(
|
|
title: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueUidOptionsByTitle($title: String!) {
|
|
issues(filter: {title: {containsIgnoreCase: $title}}) {nodes{id, identifier, title}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { title });
|
|
const qResults = data.issues?.nodes || [];
|
|
return Object.fromEntries(
|
|
qResults.map((t) => [t.id, `${t.identifier}: ${t.title}`]),
|
|
);
|
|
}
|
|
|
|
async function getIssueId(
|
|
providedId?: string,
|
|
allowByTitle = false,
|
|
): Promise<string | undefined> {
|
|
if (providedId && isValidLinearId(providedId)) {
|
|
return providedId.toUpperCase();
|
|
}
|
|
if (providedId === undefined) {
|
|
// look in branch
|
|
const branch = await getCurrentBranch();
|
|
if (!branch) return undefined;
|
|
const match = branch.match(/[a-zA-Z0-9]+-[1-9][0-9]*/i);
|
|
if (match) {
|
|
return match[0].toUpperCase();
|
|
}
|
|
}
|
|
if (allowByTitle && providedId) {
|
|
const issueId = await getIssueIdByTitle(providedId);
|
|
if (issueId) {
|
|
return issueId;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getTeamId(): Promise<string | undefined> {
|
|
const teamId = getOption("team_id");
|
|
if (teamId) {
|
|
return teamId.toUpperCase();
|
|
}
|
|
const dir = await getRepoDir();
|
|
const match = dir.match(/^[a-zA-Z0-9]+/);
|
|
return match ? match[0].toUpperCase() : undefined;
|
|
}
|
|
|
|
async function getTeamUidByKey(
|
|
team: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetTeamUidByKey($team: String!) {
|
|
teams(filter: {key: {eq: $team}}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { team });
|
|
return data.teams?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getTeamUidByName(
|
|
team: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetTeamUidByName($team: String!) {
|
|
teams(filter: {name: {eq: $team}}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { team });
|
|
return data.teams?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getTeamUid(
|
|
team: string | undefined = undefined,
|
|
tryDisplayName: boolean = true,
|
|
tryKey: boolean = true,
|
|
): Promise<string | undefined> {
|
|
team = team || await getTeamId() || undefined;
|
|
if (team === undefined) return undefined;
|
|
if (tryKey) {
|
|
const teamId = await getTeamUidByKey(team);
|
|
if (teamId) return teamId;
|
|
}
|
|
if (tryDisplayName) {
|
|
const teamId = await getTeamUidByName(team);
|
|
if (teamId) return teamId;
|
|
}
|
|
}
|
|
|
|
async function getTeamUidOptionsByKey(
|
|
team: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetTeamUidOptionsByKey($team: String!) {
|
|
teams(filter: {key: {containsIgnoreCase: $team}}) {nodes{id, key, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { team });
|
|
const qResults = data.teams?.nodes || [];
|
|
// Sort teams alphabetically by key (case insensitive) and format as "Name (KEY)"
|
|
const sortedResults = qResults.sort((a, b) =>
|
|
a.key.toLowerCase().localeCompare(b.key.toLowerCase())
|
|
);
|
|
return Object.fromEntries(
|
|
sortedResults.map((
|
|
t,
|
|
) => [
|
|
t.id,
|
|
`${(t as { id: string; key: string; name: string }).name} (${t.key})`,
|
|
]),
|
|
);
|
|
}
|
|
|
|
async function getTeamUidOptionsByName(
|
|
team: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetTeamUidOptionsByName($team: String!) {
|
|
teams(filter: {name: {containsIgnoreCase: $team}}) {nodes{id, key, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { team });
|
|
const qResults = data.teams?.nodes || [];
|
|
// Sort teams alphabetically by name (case insensitive) and format as "Name (KEY)"
|
|
const sortedResults = qResults.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
return Object.fromEntries(
|
|
sortedResults.map((t) => [t.id, `${t.name} (${t.key})`]),
|
|
);
|
|
}
|
|
|
|
async function getTeamUidOptions(
|
|
team: string,
|
|
tryName: boolean = true,
|
|
tryKey: boolean = true,
|
|
): Promise<Record<string, string>> {
|
|
let results: Record<string, string> = {};
|
|
if (tryKey) {
|
|
results = await getTeamUidOptionsByKey(team);
|
|
}
|
|
if (tryName) {
|
|
results = {
|
|
...results,
|
|
...await getTeamUidOptionsByName(team),
|
|
};
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function getUserUidByDisplayName(
|
|
username: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetUserUidByDisplayName($username: String!) {
|
|
users(filter: {displayName: {eq: $username}}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { username });
|
|
return data.users?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getUserUidOptionsByDisplayName(
|
|
name: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetUserUidOptionsByDisplayName($name: String!) {
|
|
users(filter: {displayName: {containsIgnoreCase: $name}}) {nodes{id, displayName}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
const qResults = data.users?.nodes || [];
|
|
return Object.fromEntries(qResults.map((t) => [t.id, t.displayName]));
|
|
}
|
|
|
|
async function getUserUidOptionsByName(
|
|
name: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetUserUidOptionsByName($name: String!) {
|
|
users(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
const qResults = data.users?.nodes || [];
|
|
return Object.fromEntries(qResults.map((t) => [t.id, t.name]));
|
|
}
|
|
|
|
async function getUserUidOptions(
|
|
name: string,
|
|
tryDisplayName: boolean = true,
|
|
tryName: boolean = true,
|
|
): Promise<Record<string, string>> {
|
|
let results: Record<string, string> = {};
|
|
if (tryDisplayName) {
|
|
results = await getUserUidOptionsByDisplayName(name);
|
|
}
|
|
if (tryName) {
|
|
results = {
|
|
...results,
|
|
...await getUserUidOptionsByName(name),
|
|
};
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function getUserId(
|
|
username: string | undefined,
|
|
): Promise<string | undefined> {
|
|
if (username === undefined || username === "self") {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetViewerId {
|
|
viewer {id}
|
|
}
|
|
`);
|
|
const data = await client.request(query, {});
|
|
return data.viewer.id;
|
|
} else {
|
|
return await getUserUidByDisplayName(username);
|
|
}
|
|
}
|
|
|
|
async function getIssueLabelUidByName(
|
|
name: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueLabelUidByName($name: String!) {
|
|
issueLabels(filter: {name: {eq: $name}}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
return data.issueLabels?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getIssueLabelUidOptionsByName(
|
|
name: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueLabelUidOptionsByName($name: String!) {
|
|
issueLabels(filter: {name: {containsIgnoreCase: $name}}) {nodes{id, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name });
|
|
const qResults = data.issueLabels?.nodes || [];
|
|
// Sort labels alphabetically (case insensitive)
|
|
const sortedResults = qResults.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
return Object.fromEntries(sortedResults.map((t) => [t.id, t.name]));
|
|
}
|
|
|
|
async function getIssueLabelUidByNameForTeam(
|
|
name: string,
|
|
teamId: string,
|
|
): Promise<string | undefined> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueLabelUidByNameForTeam($name: String!, $teamId: ID!) {
|
|
issueLabels(filter: {
|
|
name: {eq: $name},
|
|
or: [
|
|
{ team: { id: { eq: $teamId } } },
|
|
{ team: { null: true } }
|
|
]
|
|
}) {nodes{id}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name, teamId });
|
|
return data.issueLabels?.nodes[0]?.id;
|
|
}
|
|
|
|
async function getIssueLabelUidOptionsByNameForTeam(
|
|
name: string,
|
|
teamId: string,
|
|
): Promise<Record<string, string>> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetIssueLabelUidOptionsByNameForTeam($name: String!, $teamId: ID!) {
|
|
issueLabels(filter: {
|
|
name: {containsIgnoreCase: $name},
|
|
or: [
|
|
{ team: { id: { eq: $teamId } } },
|
|
{ team: { null: true } }
|
|
]
|
|
}) {nodes{id, name}}
|
|
}
|
|
`);
|
|
const data = await client.request(query, { name, teamId });
|
|
const qResults = data.issueLabels?.nodes || [];
|
|
// Sort labels alphabetically (case insensitive)
|
|
const sortedResults = qResults.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
return Object.fromEntries(sortedResults.map((t) => [t.id, t.name]));
|
|
}
|
|
|
|
async function getAllTeams(): Promise<
|
|
Array<{ id: string; key: string; name: string }>
|
|
> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetAllTeams {
|
|
teams {
|
|
nodes {
|
|
id
|
|
key
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
const data = await client.request(query);
|
|
const teams = data.teams?.nodes || [];
|
|
// Sort teams alphabetically by name (case insensitive)
|
|
return teams.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
}
|
|
|
|
async function getAllLabels(): Promise<
|
|
Array<{ id: string; name: string; color: string }>
|
|
> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetAllLabels {
|
|
issueLabels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const result = await client.request(query);
|
|
const labels = result.issueLabels.nodes;
|
|
// Sort labels alphabetically (case insensitive)
|
|
return labels.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
}
|
|
|
|
async function getLabelsForTeam(teamId: string): Promise<
|
|
Array<{ id: string; name: string; color: string }>
|
|
> {
|
|
const client = getGraphQLClient();
|
|
const query = gql(`
|
|
query GetLabelsForTeam($teamId: ID!) {
|
|
issueLabels(filter: {
|
|
or: [
|
|
{ team: { id: { eq: $teamId } } },
|
|
{ team: { null: true } }
|
|
]
|
|
}) {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const result = await client.request(query, { teamId });
|
|
const labels = result.issueLabels.nodes;
|
|
// Sort labels alphabetically (case insensitive)
|
|
return labels.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
}
|
|
|
|
async function selectOption(
|
|
dataName: string,
|
|
originalValue: string,
|
|
options: Record<string, string>,
|
|
): Promise<string | undefined> {
|
|
const NO = Object();
|
|
const keys = Object.keys(options);
|
|
if (keys.length === 0) {
|
|
return undefined;
|
|
} else if (keys.length === 1) {
|
|
const key = keys[0];
|
|
const result = await Select.prompt({
|
|
message: `${dataName} named ${originalValue} does not exist, but ${
|
|
options[key]
|
|
} exists. Is this what you meant?`,
|
|
options: [
|
|
{ name: "yes", value: key },
|
|
{ name: "no", value: NO },
|
|
],
|
|
});
|
|
return result === NO ? undefined : result;
|
|
} else {
|
|
const result = await Select.prompt({
|
|
message:
|
|
`${dataName} with ${originalValue} does not exist, but the following exist. Is any of these what you meant?`,
|
|
options: [
|
|
...Object.entries(options).map(([value, name]: [string, string]) => ({
|
|
name,
|
|
value,
|
|
})),
|
|
{ name: "none of the above", value: NO },
|
|
],
|
|
});
|
|
return result === NO ? undefined : result;
|
|
}
|
|
}
|
|
|
|
async function doStartIssue(issueId: string, teamId: string) {
|
|
const { branchName } = await fetchIssueDetails(issueId, true);
|
|
|
|
// Check if branch exists
|
|
if (await branchExists(branchName)) {
|
|
const answer = await Select.prompt({
|
|
message:
|
|
`Branch ${branchName} already exists. What would you like to do?`,
|
|
options: [
|
|
{ name: "Switch to existing branch", value: "switch" },
|
|
{ name: "Create new branch with suffix", value: "create" },
|
|
],
|
|
});
|
|
|
|
if (answer === "switch") {
|
|
const process = new Deno.Command("git", {
|
|
args: ["checkout", branchName],
|
|
});
|
|
await process.output();
|
|
console.log(`✓ Switched to '${branchName}'`);
|
|
} else {
|
|
// Find next available suffix
|
|
let suffix = 1;
|
|
let newBranch = `${branchName}-${suffix}`;
|
|
while (await branchExists(newBranch)) {
|
|
suffix++;
|
|
newBranch = `${branchName}-${suffix}`;
|
|
}
|
|
|
|
const process = new Deno.Command("git", {
|
|
args: ["checkout", "-b", newBranch],
|
|
});
|
|
await process.output();
|
|
console.log(`✓ Created and switched to branch '${newBranch}'`);
|
|
}
|
|
} else {
|
|
// Create and checkout the branch
|
|
const process = new Deno.Command("git", {
|
|
args: ["checkout", "-b", branchName],
|
|
});
|
|
await process.output();
|
|
console.log(`✓ Created and switched to branch '${branchName}'`);
|
|
}
|
|
|
|
// Update issue state
|
|
try {
|
|
const state = await getStartedState(teamId);
|
|
if (!issueId) {
|
|
console.error("No issue ID resolved");
|
|
Deno.exit(1);
|
|
}
|
|
await updateIssueState(issueId, state.id);
|
|
console.log(`✓ Issue state updated to '${state.name}'`);
|
|
} catch (error) {
|
|
console.error("Failed to update issue state:", error);
|
|
}
|
|
}
|
|
|
|
export function getGraphQLClient(): GraphQLClient {
|
|
const apiKey = getOption("api_key");
|
|
if (!apiKey) {
|
|
throw new Error(
|
|
"api_key is not set via command line, configuration file, or environment.",
|
|
);
|
|
}
|
|
|
|
return new GraphQLClient("https://api.linear.app/graphql", {
|
|
headers: {
|
|
Authorization: apiKey,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function fetchIssuesForState(teamId: string, state: string) {
|
|
const sort = getOption("issue_sort") as "manual" | "priority" | undefined;
|
|
if (!sort) {
|
|
console.error(
|
|
"Sort must be provided via configuration file or LINEAR_ISSUE_SORT environment variable",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const query = gql(`
|
|
query GetIssuesForState($teamId: String!, $sort: [IssueSortInput!], $states: [String!]) {
|
|
issues(
|
|
filter: {
|
|
team: { key: { eq: $teamId } }
|
|
assignee: { isMe: { eq: true } }
|
|
state: { type: { in: $states } }
|
|
}
|
|
sort: $sort
|
|
) {
|
|
nodes {
|
|
id
|
|
identifier
|
|
title
|
|
priority
|
|
estimate
|
|
state {
|
|
id
|
|
name
|
|
color
|
|
}
|
|
labels {
|
|
nodes {
|
|
id
|
|
name
|
|
color
|
|
}
|
|
}
|
|
updatedAt
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const sortPayload = sort === "manual"
|
|
? [{ manual: { nulls: "last" as const, order: "Ascending" as const } }]
|
|
: [{ priority: { nulls: "last" as const, order: "Descending" as const } }];
|
|
|
|
const client = getGraphQLClient();
|
|
return await client.request(query, {
|
|
teamId,
|
|
sort: sortPayload,
|
|
states: [state],
|
|
});
|
|
}
|
|
|
|
function getPriorityDisplay(priority: number): string {
|
|
if (priority === 0) {
|
|
return "---";
|
|
} else if (priority === 1 || priority === 2) {
|
|
return "▄▆█";
|
|
} else if (priority === 3) {
|
|
return "▄▆ ";
|
|
} else if (priority === 4) {
|
|
return "▄ ";
|
|
}
|
|
return priority.toString();
|
|
}
|
|
|
|
async function fetchIssueDetails(
|
|
issueId: string,
|
|
showSpinner = false,
|
|
): Promise<
|
|
{
|
|
title: string;
|
|
description?: string | null | undefined;
|
|
url: string;
|
|
branchName: string;
|
|
}
|
|
> {
|
|
const spinner = showSpinner ? new Spinner() : null;
|
|
spinner?.start();
|
|
try {
|
|
const query = gql(`
|
|
query GetIssueDetails($id: String!) {
|
|
issue(id: $id) { title, description, url, branchName }
|
|
}
|
|
`);
|
|
const client = getGraphQLClient();
|
|
const data = await client.request(query, { id: issueId });
|
|
spinner?.stop();
|
|
return data.issue;
|
|
} catch (error) {
|
|
spinner?.stop();
|
|
console.error("✗ Failed to fetch issue details");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function openTeamPage(options: { app?: boolean } = {}) {
|
|
const teamId = await getTeamId();
|
|
if (!teamId) {
|
|
console.error(
|
|
"Could not determine team id from configuration or directory name.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const workspace = getOption("workspace");
|
|
if (!workspace) {
|
|
console.error(
|
|
"workspace is not set via command line, configuration file, or environment.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const filterObj = {
|
|
"and": [{ "assignee": { "or": [{ "isMe": { "eq": true } }] } }],
|
|
};
|
|
const filter = encodeBase64(JSON.stringify(filterObj)).replace(/=/g, "");
|
|
const url =
|
|
`https://linear.app/${workspace}/team/${teamId}/active?filter=${filter}`;
|
|
await open(url, options.app ? { app: { name: "Linear" } } : undefined);
|
|
}
|
|
|
|
async function openIssuePage(
|
|
providedId?: string,
|
|
options: { app?: boolean; web?: boolean } = {},
|
|
) {
|
|
const issueId = await getIssueId(providedId);
|
|
if (!issueId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const workspace = getOption("workspace");
|
|
if (!workspace) {
|
|
console.error(
|
|
"workspace is not set via command line, configuration file, or environment.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const url = `https://linear.app/${workspace}/issue/${issueId}`;
|
|
const destination = options.app ? "Linear.app" : "web browser";
|
|
console.log(`Opening ${url} in ${destination}`);
|
|
await open(url, options.app ? { app: { name: "Linear" } } : undefined);
|
|
}
|
|
|
|
const teamCommand = new Command()
|
|
.description(
|
|
"Manage Linear teams (deprecated: use `linear issue list --app` instead)",
|
|
)
|
|
.action(() => {
|
|
console.error(
|
|
"Warning: 'linear team' is deprecated and will be removed in a future release.",
|
|
);
|
|
console.error("Please use 'linear issue list --app' instead.");
|
|
return openTeamPage({ app: true });
|
|
})
|
|
.command("open", "Open the team page in Linear.app")
|
|
.alias("o")
|
|
.action(() => openTeamPage({ app: true }))
|
|
.command("id", "Print the team id derived from the repository name")
|
|
.action(async () => {
|
|
const teamId = await getTeamId();
|
|
if (teamId) {
|
|
console.log(teamId);
|
|
} else {
|
|
console.error("Could not determine team id from directory name.");
|
|
Deno.exit(1);
|
|
}
|
|
})
|
|
.command(
|
|
"autolinks",
|
|
"Configure GitHub repository autolinks for Linear issues",
|
|
)
|
|
.action(async () => {
|
|
const teamId = await getTeamId();
|
|
if (!teamId) {
|
|
console.error("Could not determine team id from directory name.");
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const workspace = getOption("workspace");
|
|
if (!workspace) {
|
|
console.error(
|
|
"workspace is not set via command line, configuration file, or environment.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const process = new Deno.Command("gh", {
|
|
args: [
|
|
"api",
|
|
"repos/{owner}/{repo}/autolinks",
|
|
"-f",
|
|
`key_prefix=${teamId}-`,
|
|
"-f",
|
|
`url_template=https://linear.app/${workspace}/issue/${teamId}-<num>`,
|
|
],
|
|
stdin: "inherit",
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
|
|
const status = await process.spawn().status;
|
|
if (!status.success) {
|
|
console.error("Failed to configure autolinks");
|
|
Deno.exit(1);
|
|
}
|
|
});
|
|
|
|
const issueCommand = new Command()
|
|
.description("Manage Linear issues")
|
|
.arguments("[issueId:string]")
|
|
.action((_, issueId) => openIssuePage(issueId))
|
|
.command(
|
|
"open",
|
|
"Open the issue in Linear.app (deprecated: use `linear issue view --app` instead)",
|
|
)
|
|
.alias("o")
|
|
.arguments("[issueId:string]")
|
|
.action((_, issueId) => {
|
|
console.error(
|
|
"Warning: 'linear issue open' is deprecated and will be removed in a future release.",
|
|
);
|
|
console.error("Please use 'linear issue view --app' instead.");
|
|
return openIssuePage(issueId, { app: true });
|
|
})
|
|
.command(
|
|
"print",
|
|
"Print the issue details (deprecated: use `linear issue view` instead)",
|
|
)
|
|
.alias("p")
|
|
.arguments("[issueId:string]")
|
|
.option("--no-color", "Disable colored output")
|
|
.option("--no-interactive", "Disable interactive prompts")
|
|
.action(async ({ color, interactive }, issueId) => {
|
|
console.error(
|
|
"Warning: 'linear issue print' is deprecated and will be removed in a future release.",
|
|
);
|
|
console.error("Please use 'linear issue view' instead.");
|
|
const resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const showSpinner = color && interactive && Deno.stdout.isTerminal();
|
|
const { title, description } = await fetchIssueDetails(
|
|
resolvedId,
|
|
showSpinner,
|
|
);
|
|
const markdown = `# ${title}${description ? "\n\n" + description : ""}`;
|
|
if (color && Deno.stdout.isTerminal()) {
|
|
console.log(renderMarkdown(markdown));
|
|
} else {
|
|
console.log(markdown);
|
|
}
|
|
})
|
|
.command("id", "Print the issue based on the current git branch")
|
|
.action(async (_) => {
|
|
const resolvedId = await getIssueId();
|
|
if (resolvedId) {
|
|
console.log(resolvedId);
|
|
} else {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
})
|
|
.command("list", "List your issues")
|
|
.type("sort", SortType)
|
|
.type("state", StateType)
|
|
.option(
|
|
"--sort <sort:sort>",
|
|
"Sort order (can also be set via LINEAR_ISSUE_SORT)",
|
|
{
|
|
required: false,
|
|
},
|
|
)
|
|
.option(
|
|
"-s, --state <state:state>",
|
|
"Filter by issue state",
|
|
{
|
|
default: "unstarted",
|
|
},
|
|
)
|
|
.option("-w, --web", "Open in web browser")
|
|
.option("-a, --app", "Open in Linear.app")
|
|
.action(async ({ sort: sortFlag, state, web, app }) => {
|
|
if (web || app) {
|
|
await openTeamPage({ app });
|
|
return;
|
|
}
|
|
const sort = sortFlag ||
|
|
getOption("issue_sort") as "manual" | "priority" | undefined;
|
|
if (!sort) {
|
|
console.error(
|
|
"Sort must be provided via command line flag, configuration file, or LINEAR_ISSUE_SORT environment variable",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
if (!SortType.values().includes(sort)) {
|
|
console.error(`Sort must be one of: ${SortType.values().join(", ")}`);
|
|
Deno.exit(1);
|
|
}
|
|
const teamId = await getTeamId();
|
|
if (!teamId) {
|
|
console.error("Could not determine team id from directory name.");
|
|
Deno.exit(1);
|
|
}
|
|
|
|
try {
|
|
const result = await fetchIssuesForState(teamId, state);
|
|
const issues = result.issues?.nodes || [];
|
|
|
|
if (issues.length === 0) {
|
|
console.log("No unstarted issues found.");
|
|
return;
|
|
}
|
|
|
|
// Define column widths first
|
|
const { columns } = Deno.consoleSize();
|
|
const PRIORITY_WIDTH = 4;
|
|
const ID_WIDTH = 8;
|
|
const LABEL_WIDTH = columns <= 100 ? 12 : 24; // adjust label width based on terminal size
|
|
const ESTIMATE_WIDTH = 2; // fixed width for estimate
|
|
const STATE_WIDTH = 12; // fixed width for state
|
|
const SPACE_WIDTH = 4;
|
|
const updatedHeader = "UPDATED";
|
|
const UPDATED_WIDTH = Math.max(
|
|
unicodeWidth(updatedHeader),
|
|
...issues.map((issue) =>
|
|
unicodeWidth(getTimeAgo(new Date(issue.updatedAt)))
|
|
),
|
|
);
|
|
|
|
type TableRow = {
|
|
priorityStr: string;
|
|
priorityStyles: string[];
|
|
identifier: string;
|
|
title: string;
|
|
labelsFormat: string;
|
|
labelsStyles: string[];
|
|
state: string;
|
|
stateStyles: string[];
|
|
timeAgo: string;
|
|
estimate: number | null | undefined;
|
|
};
|
|
|
|
const tableData: Array<TableRow> = issues.map((issue) => {
|
|
// First build the plain text version to measure length
|
|
const plainLabels = issue.labels.nodes.map((l) => l.name).join(
|
|
", ",
|
|
);
|
|
let labelsFormat: string;
|
|
let labelsStyles: string[] = [];
|
|
|
|
if (issue.labels.nodes.length === 0) {
|
|
labelsFormat = "";
|
|
} else {
|
|
const truncatedLabels = plainLabels.length > LABEL_WIDTH
|
|
? plainLabels.slice(0, LABEL_WIDTH - 3) + "..."
|
|
: plainLabels;
|
|
|
|
// Then format the truncated version with colors
|
|
labelsFormat = truncatedLabels
|
|
.split(", ")
|
|
.map((name) => `%c${name}%c`)
|
|
.join(", ");
|
|
labelsStyles = issue.labels.nodes
|
|
.filter((_, i) => i < truncatedLabels.split(", ").length)
|
|
.flatMap((l) => [`color: ${l.color}`, ""]);
|
|
}
|
|
const updatedAt = new Date(issue.updatedAt);
|
|
const timeAgo = getTimeAgo(updatedAt);
|
|
|
|
let priorityStr = "";
|
|
let priorityStyles: string[] = [];
|
|
if (issue.priority === 0) {
|
|
priorityStr = "%c---%c";
|
|
priorityStyles = ["color: silver", ""];
|
|
} else if (issue.priority === 1 || issue.priority === 2) {
|
|
// ▄▆█
|
|
priorityStr = "%c▄%c▆%c█%c";
|
|
priorityStyles = ["", "", "", ""];
|
|
} else if (issue.priority === 3) {
|
|
priorityStr = "%c▄%c▆%c█%c";
|
|
priorityStyles = ["", "", "color: silver", ""];
|
|
} else if (issue.priority === 4) {
|
|
priorityStr = "%c▄%c▆%c█%c";
|
|
priorityStyles = ["", "color: silver", "color: silver", ""];
|
|
} else {
|
|
priorityStr = issue.priority.toString();
|
|
priorityStyles = [];
|
|
}
|
|
|
|
return {
|
|
priorityStr,
|
|
priorityStyles,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
labelsFormat,
|
|
labelsStyles,
|
|
state: `%c${issue.state.name}%c`,
|
|
stateStyles: [`color: ${issue.state.color}`, ""],
|
|
timeAgo,
|
|
estimate: issue.estimate,
|
|
};
|
|
});
|
|
|
|
const fixed = PRIORITY_WIDTH + ID_WIDTH + UPDATED_WIDTH + SPACE_WIDTH +
|
|
LABEL_WIDTH + ESTIMATE_WIDTH + STATE_WIDTH + SPACE_WIDTH; // sum of fixed columns including spacing for estimate
|
|
const PADDING = 1;
|
|
const maxTitleWidth = Math.max(
|
|
...tableData.map((row) => unicodeWidth(row.title)),
|
|
);
|
|
const availableWidth = Math.max(columns - PADDING - fixed, 0);
|
|
const titleWidth = Math.min(maxTitleWidth, availableWidth); // use smaller of max title width or available space
|
|
const headerCells = [
|
|
padDisplay("◌", PRIORITY_WIDTH),
|
|
padDisplay("ID", ID_WIDTH),
|
|
padDisplay("TITLE", titleWidth),
|
|
padDisplay("LABELS", LABEL_WIDTH),
|
|
padDisplay("E", ESTIMATE_WIDTH),
|
|
padDisplay("STATE", STATE_WIDTH),
|
|
padDisplay(updatedHeader, UPDATED_WIDTH),
|
|
];
|
|
let headerMsg = "";
|
|
const headerStyles: string[] = [];
|
|
headerCells.forEach((cell, index) => {
|
|
headerMsg += `%c${cell}`;
|
|
headerStyles.push("text-decoration: underline");
|
|
if (index < headerCells.length - 1) {
|
|
headerMsg += "%c %c"; // non-underlined space between cells
|
|
headerStyles.push("text-decoration: none");
|
|
headerStyles.push("text-decoration: underline");
|
|
}
|
|
});
|
|
console.log(headerMsg, ...headerStyles);
|
|
|
|
// Print each issue
|
|
for (const row of tableData) {
|
|
const {
|
|
priorityStr,
|
|
priorityStyles,
|
|
identifier,
|
|
title,
|
|
labelsFormat,
|
|
labelsStyles,
|
|
state,
|
|
stateStyles,
|
|
timeAgo,
|
|
} = row;
|
|
const truncTitle = title.length > titleWidth
|
|
? title.slice(0, titleWidth - 3) + "..."
|
|
: padDisplay(title, titleWidth);
|
|
|
|
console.log(
|
|
`${padDisplayFormatted(priorityStr, 4)} ${
|
|
padDisplay(identifier, 8)
|
|
} ${truncTitle} ${padDisplayFormatted(labelsFormat, LABEL_WIDTH)} ${
|
|
padDisplay(row.estimate?.toString() || "-", ESTIMATE_WIDTH)
|
|
} ${padDisplayFormatted(state, STATE_WIDTH)} %c${
|
|
padDisplay(timeAgo, UPDATED_WIDTH)
|
|
}%c`,
|
|
...priorityStyles,
|
|
...labelsStyles,
|
|
...stateStyles,
|
|
"color: gray",
|
|
"",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch issues:", error);
|
|
Deno.exit(1);
|
|
}
|
|
})
|
|
.command("title", "Print the issue title")
|
|
.arguments("[issueId:string]")
|
|
.action(async (_, issueId) => {
|
|
const resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
const { title } = await fetchIssueDetails(resolvedId, false);
|
|
console.log(title);
|
|
})
|
|
.command("start", "Start working on an issue")
|
|
.arguments("[issueId:string]")
|
|
.action(async (_, issueId) => {
|
|
const teamId = await getTeamId();
|
|
if (!teamId) {
|
|
console.error("Could not determine team ID");
|
|
Deno.exit(1);
|
|
}
|
|
let resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
try {
|
|
const result = await fetchIssuesForState(teamId, "unstarted");
|
|
const issues = result.issues?.nodes || [];
|
|
|
|
if (issues.length === 0) {
|
|
console.error("No unstarted issues found.");
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const answer = await Select.prompt({
|
|
message: "Select an issue to start:",
|
|
options: issues.map((
|
|
issue: { identifier: string; title: string; priority: number },
|
|
) => ({
|
|
name: getPriorityDisplay(issue.priority) +
|
|
` ${issue.identifier}: ${issue.title}`,
|
|
value: issue.identifier,
|
|
})),
|
|
});
|
|
|
|
resolvedId = answer as string;
|
|
} catch (error) {
|
|
console.error("Failed to fetch issues:", error);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
|
|
if (!resolvedId) {
|
|
console.error("No issue ID resolved");
|
|
Deno.exit(1);
|
|
}
|
|
await doStartIssue(resolvedId, teamId);
|
|
})
|
|
.command("view", "View issue details (default) or open in browser/app")
|
|
.alias("v")
|
|
.arguments("[issueId:string]")
|
|
.option("-w, --web", "Open in web browser")
|
|
.option("-a, --app", "Open in Linear.app")
|
|
.action(async ({ web, app }, issueId) => {
|
|
if (web || app) {
|
|
await openIssuePage(issueId, { app, web: !app });
|
|
return;
|
|
}
|
|
|
|
const resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const { title, description } = await fetchIssueDetails(
|
|
resolvedId,
|
|
Deno.stdout.isTerminal(),
|
|
);
|
|
const markdown = `# ${title}${description ? "\n\n" + description : ""}`;
|
|
if (Deno.stdout.isTerminal()) {
|
|
console.log(renderMarkdown(markdown));
|
|
} else {
|
|
console.log(markdown);
|
|
}
|
|
})
|
|
.command("url", "Print the issue URL")
|
|
.arguments("[issueId:string]")
|
|
.action(async (_, issueId) => {
|
|
const resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
const { url } = await fetchIssueDetails(resolvedId, false);
|
|
console.log(url);
|
|
})
|
|
.command("pull-request", "Create a GitHub pull request with issue details")
|
|
.alias("pr")
|
|
.option(
|
|
"--base <branch:string>",
|
|
"The branch into which you want your code merged",
|
|
)
|
|
.option(
|
|
"--draft",
|
|
"Create the pull request as a draft",
|
|
)
|
|
.option(
|
|
"-t, --title <title:string>",
|
|
"Optional title for the pull request (Linear issue ID will be prefixed)",
|
|
)
|
|
.arguments("[issueId:string]")
|
|
.action(async ({ base, draft, title: customTitle }, issueId) => {
|
|
const resolvedId = await getIssueId(issueId);
|
|
if (!resolvedId) {
|
|
console.error(
|
|
"The current branch does not contain a valid linear issue id.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
const { title, url } = await fetchIssueDetails(
|
|
resolvedId,
|
|
Deno.stdout.isTerminal(),
|
|
);
|
|
|
|
const process = new Deno.Command("gh", {
|
|
args: [
|
|
"pr",
|
|
"create",
|
|
"--title",
|
|
`${resolvedId} ${customTitle ?? title}`,
|
|
"--body",
|
|
url,
|
|
...(base ? ["--base", base] : []),
|
|
...(draft ? ["--draft"] : ["--web"]),
|
|
],
|
|
stdin: "inherit",
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
|
|
const status = await process.spawn().status;
|
|
if (!status.success) {
|
|
console.error("Failed to create pull request");
|
|
Deno.exit(1);
|
|
}
|
|
});
|
|
|
|
async function promptInteractiveIssueCreation(
|
|
preStartedStatesPromise?: Promise<
|
|
Array<{ id: string; name: string; type: string; position: number }>
|
|
>,
|
|
): Promise<{
|
|
title: string;
|
|
teamId: string;
|
|
assigneeId?: string;
|
|
priority?: number;
|
|
estimate?: number;
|
|
labelIds: string[];
|
|
description?: string;
|
|
stateId?: string;
|
|
start: boolean;
|
|
}> {
|
|
// Determine team FIRST, before asking for title
|
|
const defaultTeamId = await getTeamId();
|
|
let teamId: string;
|
|
let statesPromise: Promise<
|
|
Array<{ id: string; name: string; type: string; position: number }>
|
|
>;
|
|
|
|
if (defaultTeamId) {
|
|
const teamUid = await getTeamUid(defaultTeamId);
|
|
if (teamUid) {
|
|
teamId = teamUid;
|
|
// Use pre-started promise if available, otherwise start now
|
|
statesPromise = preStartedStatesPromise || getWorkflowStates(teamId);
|
|
} else {
|
|
// Fallback to team selection if we can't resolve the team
|
|
const teams = await getAllTeams();
|
|
|
|
const selectedTeamId = await Select.prompt({
|
|
message: "Which team should this issue belong to?",
|
|
search: true,
|
|
searchLabel: "Search teams",
|
|
options: teams.map((team) => ({
|
|
name: `${team.name} (${team.key})`,
|
|
value: team.id,
|
|
})),
|
|
});
|
|
|
|
const team = teams.find((t) => t.id === selectedTeamId);
|
|
|
|
if (!team) {
|
|
console.error(`Could not find team: ${selectedTeamId}`);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
teamId = team.id;
|
|
// Start fetching workflow states after team selection (can't use pre-started promise for different team)
|
|
statesPromise = getWorkflowStates(teamId);
|
|
}
|
|
} else {
|
|
// No default team, prompt for selection
|
|
const teams = await getAllTeams();
|
|
|
|
const selectedTeamId = await Select.prompt({
|
|
message: "Which team should this issue belong to?",
|
|
search: true,
|
|
searchLabel: "Search teams",
|
|
options: teams.map((team) => ({
|
|
name: `${team.name} (${team.key})`,
|
|
value: team.id,
|
|
})),
|
|
});
|
|
|
|
const team = teams.find((t) => t.id === selectedTeamId);
|
|
|
|
if (!team) {
|
|
console.error(`Could not find team: ${selectedTeamId}`);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
teamId = team.id;
|
|
// Start fetching workflow states after team selection (can't use pre-started promise for different team)
|
|
statesPromise = getWorkflowStates(teamId);
|
|
}
|
|
|
|
const title = await Input.prompt({
|
|
message: "What's the title of your issue?",
|
|
minLength: 1,
|
|
});
|
|
|
|
// Select workflow state - await the promise we started earlier
|
|
const states = await statesPromise;
|
|
let stateId: string | undefined;
|
|
|
|
if (states.length > 0) {
|
|
// Find the first 'unstarted' state as default
|
|
const defaultState = states.find((s) => s.type === "unstarted") ||
|
|
states[0];
|
|
|
|
stateId = await Select.prompt({
|
|
message: "Which workflow state should this issue be in?",
|
|
options: states.map((state) => ({
|
|
name: `${state.name} (${state.type})`,
|
|
value: state.id,
|
|
})),
|
|
default: defaultState.id,
|
|
});
|
|
}
|
|
|
|
const assignToSelf = await Select.prompt({
|
|
message: "Assign this issue to yourself?",
|
|
options: [
|
|
{ name: "No", value: false },
|
|
{ name: "Yes", value: true },
|
|
],
|
|
default: false,
|
|
});
|
|
|
|
const assigneeId = assignToSelf ? await getUserId("self") : undefined;
|
|
|
|
const priority = await Select.prompt({
|
|
message: "What priority should this issue have?",
|
|
options: [
|
|
{ name: "No priority", value: 0 },
|
|
{ name: "Urgent (1)", value: 1 },
|
|
{ name: "High (2)", value: 2 },
|
|
{ name: "Medium (3)", value: 3 },
|
|
{ name: "Low (4)", value: 4 },
|
|
],
|
|
default: 0,
|
|
});
|
|
|
|
const labels = await getLabelsForTeam(teamId);
|
|
const labelIds: string[] = [];
|
|
|
|
if (labels.length > 0) {
|
|
const hasLabels = await Select.prompt({
|
|
message: "Do you want to add labels?",
|
|
options: [
|
|
{ name: "No", value: false },
|
|
{ name: "Yes", value: true },
|
|
],
|
|
default: false,
|
|
});
|
|
|
|
if (hasLabels) {
|
|
const selectedLabelIds = await Checkbox.prompt({
|
|
message: "Select labels (use space to select, enter to confirm)",
|
|
search: true,
|
|
searchLabel: "Search labels",
|
|
options: labels.map((label) => ({
|
|
name: label.name,
|
|
value: label.id,
|
|
})),
|
|
});
|
|
labelIds.push(...selectedLabelIds);
|
|
}
|
|
}
|
|
|
|
// Get editor name for prompt
|
|
const editorName = await getEditor();
|
|
const editorDisplayName = editorName ? editorName.split("/").pop() : null;
|
|
|
|
const promptMessage = editorDisplayName
|
|
? `Body [(e) to launch ${editorDisplayName}]`
|
|
: "Body";
|
|
|
|
const description = await Input.prompt({
|
|
message: promptMessage,
|
|
default: "",
|
|
});
|
|
|
|
let finalDescription: string | undefined;
|
|
if (description === "e" && editorDisplayName) {
|
|
console.log(`Opening ${editorDisplayName}...`);
|
|
finalDescription = await openEditor();
|
|
if (finalDescription && finalDescription.length > 0) {
|
|
console.log(
|
|
`Description entered (${finalDescription.length} characters)`,
|
|
);
|
|
} else {
|
|
console.log("No description entered");
|
|
finalDescription = undefined;
|
|
}
|
|
} else if (description === "e" && !editorDisplayName) {
|
|
console.error(
|
|
"No editor found. Please set EDITOR environment variable or configure git editor with: git config --global core.editor <editor>",
|
|
);
|
|
finalDescription = undefined;
|
|
} else if (description.trim().length > 0) {
|
|
finalDescription = description.trim();
|
|
}
|
|
|
|
const start = await Select.prompt({
|
|
message:
|
|
"Start working on this issue now? (creates branch and updates status)",
|
|
options: [
|
|
{ name: "No", value: false },
|
|
{ name: "Yes", value: true },
|
|
],
|
|
default: false,
|
|
});
|
|
|
|
return {
|
|
title,
|
|
teamId,
|
|
assigneeId,
|
|
priority: priority === 0 ? undefined : priority,
|
|
estimate: undefined,
|
|
labelIds,
|
|
description: finalDescription,
|
|
stateId,
|
|
start,
|
|
};
|
|
}
|
|
|
|
const createCommand = new Command()
|
|
.name("create")
|
|
.description("Create a linear issue")
|
|
.option(
|
|
"-s, --start",
|
|
"Start the issue after creation",
|
|
)
|
|
.option(
|
|
"-a, --assignee <assignee:string>",
|
|
"Assign the issue to 'self' or someone (by username or name)",
|
|
)
|
|
.option(
|
|
"--due-date <dueDate:string>",
|
|
"Due date of the issue",
|
|
)
|
|
.option(
|
|
"-p, --parent <parent:string>",
|
|
"Parent issue (if any) as a team_number code",
|
|
)
|
|
.option(
|
|
"--priority <priority:number>",
|
|
"Priority of the issue (1-4, descending priority)",
|
|
)
|
|
.option(
|
|
"--estimate <estimate:number>",
|
|
"Points estimate of the issue",
|
|
)
|
|
.option(
|
|
"-d, --description <description:string>",
|
|
"Description of the issue",
|
|
)
|
|
.option(
|
|
"-l, --label [label...:string]",
|
|
"Issue label associated with the issue. May be repeated.",
|
|
)
|
|
.option(
|
|
"--team <team:string>",
|
|
"Team associated with the issue (if not your default team)",
|
|
)
|
|
.option(
|
|
"--project <project:string>",
|
|
"Name of the project with the issue",
|
|
)
|
|
.option(
|
|
"--state <state:string>",
|
|
"Workflow state for the issue (by name or type)",
|
|
)
|
|
.option(
|
|
"--no-use-default-template",
|
|
"Do not use default template for the issue",
|
|
)
|
|
.option("--no-color", "Disable colored output")
|
|
.option("--no-interactive", "Disable interactive prompts")
|
|
.option("-t, --title <title:string>", "Title of the issue")
|
|
.action(
|
|
async (
|
|
{
|
|
start,
|
|
assignee,
|
|
dueDate,
|
|
useDefaultTemplate,
|
|
parent,
|
|
priority,
|
|
estimate,
|
|
description,
|
|
label: labels,
|
|
team,
|
|
project,
|
|
state,
|
|
color,
|
|
interactive,
|
|
title,
|
|
},
|
|
) => {
|
|
interactive = interactive && Deno.stdout.isTerminal();
|
|
|
|
// If no flags are provided (just title is empty), use interactive mode
|
|
const noFlagsProvided = !title && !assignee && !dueDate && !parent &&
|
|
priority === undefined && estimate === undefined && !description &&
|
|
(!labels || labels === true ||
|
|
(Array.isArray(labels) && labels.length === 0)) &&
|
|
!team && !project && !state && !start;
|
|
|
|
if (noFlagsProvided && interactive) {
|
|
try {
|
|
// Pre-fetch team info and start workflow states query early
|
|
const defaultTeamId = await getTeamId();
|
|
let statesPromise:
|
|
| Promise<
|
|
Array<
|
|
{ id: string; name: string; type: string; position: number }
|
|
>
|
|
>
|
|
| undefined;
|
|
|
|
if (defaultTeamId) {
|
|
const teamUid = await getTeamUid(defaultTeamId);
|
|
if (teamUid) {
|
|
// Start fetching workflow states immediately for the default team
|
|
statesPromise = getWorkflowStates(teamUid);
|
|
}
|
|
}
|
|
|
|
const interactiveData = await promptInteractiveIssueCreation(
|
|
statesPromise,
|
|
);
|
|
|
|
console.log(`Creating issue...`);
|
|
console.log();
|
|
|
|
const createIssueMutation = gql(`
|
|
mutation CreateIssue($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
success
|
|
issue { id, identifier, url, team { key } }
|
|
}
|
|
}
|
|
`);
|
|
|
|
const client = getGraphQLClient();
|
|
const data = await client.request(createIssueMutation, {
|
|
input: {
|
|
title: interactiveData.title,
|
|
assigneeId: interactiveData.assigneeId,
|
|
dueDate: undefined,
|
|
parentId: undefined,
|
|
priority: interactiveData.priority,
|
|
estimate: interactiveData.estimate,
|
|
labelIds: interactiveData.labelIds,
|
|
teamId: interactiveData.teamId,
|
|
projectId: undefined,
|
|
stateId: interactiveData.stateId,
|
|
useDefaultTemplate,
|
|
description: interactiveData.description,
|
|
},
|
|
});
|
|
|
|
if (!data.issueCreate.success) {
|
|
throw "query failed";
|
|
}
|
|
const issue = data.issueCreate.issue;
|
|
if (!issue) {
|
|
throw "Issue creation failed - no issue returned";
|
|
}
|
|
const issueId = issue.id;
|
|
console.log(
|
|
`✓ Created issue ${issue.identifier}: ${interactiveData.title}`,
|
|
);
|
|
console.log(issue.url);
|
|
|
|
if (interactiveData.start) {
|
|
const teamKey = issue.team.key;
|
|
const teamUid = await getTeamUidByKey(teamKey);
|
|
if (teamUid) {
|
|
await doStartIssue(issueId, teamUid);
|
|
}
|
|
}
|
|
return;
|
|
} catch (error) {
|
|
console.error("✗ Failed to create issue", error);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
|
|
// Fallback to flag-based mode
|
|
if (!title) {
|
|
console.error(
|
|
"Title is required when not using interactive mode. Use --title or run without any flags for interactive mode.",
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const showSpinner = color && interactive;
|
|
const spinner = showSpinner ? new Spinner() : null;
|
|
spinner?.start();
|
|
try {
|
|
team = (team === undefined)
|
|
? (await getTeamId() || undefined)
|
|
: team.toUpperCase();
|
|
let teamUid: string | undefined = undefined;
|
|
if (team !== undefined) {
|
|
teamUid = await getTeamUid(team);
|
|
if (interactive && !teamUid) {
|
|
const teamUids = await getTeamUidOptions(team);
|
|
spinner?.stop();
|
|
teamUid = await selectOption("Team", team, teamUids);
|
|
spinner?.start();
|
|
}
|
|
}
|
|
if (!teamUid) {
|
|
console.error(`Could not determine team ID for team ${team}`);
|
|
Deno.exit(1);
|
|
}
|
|
if (start && assignee === undefined) {
|
|
assignee = "self";
|
|
}
|
|
if (start && assignee !== undefined && assignee !== "self") {
|
|
console.error("Cannot use --start and a non-self --assignee");
|
|
}
|
|
let stateId: string | undefined;
|
|
if (state) {
|
|
const workflowState = await getWorkflowStateByNameOrType(
|
|
teamUid,
|
|
state,
|
|
);
|
|
if (!workflowState) {
|
|
console.error(
|
|
`Could not find workflow state '${state}' for team ${team}`,
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
stateId = workflowState.id;
|
|
}
|
|
|
|
let assigneeId = await getUserId(assignee);
|
|
if (!assigneeId && assignee !== undefined) {
|
|
if (interactive) {
|
|
const assigneeIds = await getUserUidOptions(assignee);
|
|
spinner?.stop();
|
|
assigneeId = await selectOption("User", assignee, assigneeIds);
|
|
spinner?.start();
|
|
}
|
|
if (!assigneeId) {
|
|
console.error(
|
|
`Could not determine user ID for assignee ${assignee}`,
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
const labelIds = [];
|
|
if (labels !== undefined && labels !== true && labels.length > 0) {
|
|
// sequential in case of questions
|
|
for (const label of labels) {
|
|
let labelId = await getIssueLabelUidByNameForTeam(label, teamUid);
|
|
if (!labelId && interactive) {
|
|
const labelIds = await getIssueLabelUidOptionsByNameForTeam(label, teamUid);
|
|
spinner?.stop();
|
|
labelId = await selectOption("Issue label", label, labelIds);
|
|
spinner?.start();
|
|
}
|
|
if (!labelId) {
|
|
console.error(
|
|
`Could not determine ID for issue label ${label}`,
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
labelIds.push(labelId);
|
|
}
|
|
}
|
|
let projectId: string | undefined = undefined;
|
|
if (project !== undefined) {
|
|
projectId = await getProjectUidByName(project);
|
|
if (projectId === undefined && interactive) {
|
|
const projectIds = await getProjectUidOptionsByName(project);
|
|
spinner?.stop();
|
|
projectId = await selectOption("Project", project, projectIds);
|
|
spinner?.start();
|
|
}
|
|
if (projectId === undefined) {
|
|
console.error(`Could not determine ID for project ${project}`);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
let parentUid: string | undefined = undefined;
|
|
if (parent !== undefined) {
|
|
const parentId = await getIssueId(parent, true);
|
|
if (parentId) {
|
|
parentUid = await getIssueUidByIdentifier(parentId);
|
|
}
|
|
if (parentId === undefined && interactive) {
|
|
const parentUids = await getIssueUidOptionsByTitle(parent);
|
|
spinner?.stop();
|
|
parentUid = await selectOption("Parent issue", parent, parentUids);
|
|
spinner?.start();
|
|
}
|
|
if (parentUid === undefined) {
|
|
console.error(`Could not determine ID for issue ${parent}`);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
// Date validation done at graphql level
|
|
|
|
const input = {
|
|
title,
|
|
assigneeId,
|
|
dueDate,
|
|
parentId: parentUid,
|
|
priority,
|
|
estimate,
|
|
labelIds,
|
|
teamId: teamUid,
|
|
projectId,
|
|
stateId,
|
|
useDefaultTemplate,
|
|
description,
|
|
};
|
|
spinner?.stop();
|
|
console.log(`Creating issue in ${team}`);
|
|
console.log();
|
|
spinner?.start();
|
|
|
|
const createIssueMutation = gql(`
|
|
mutation CreateIssue($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
success
|
|
issue { id, identifier, url, team { key } }
|
|
}
|
|
}
|
|
`);
|
|
|
|
const client = getGraphQLClient();
|
|
const data = await client.request(createIssueMutation, { input });
|
|
if (!data.issueCreate.success) {
|
|
throw "query failed";
|
|
}
|
|
const issue = data.issueCreate.issue;
|
|
if (!issue) {
|
|
throw "Issue creation failed - no issue returned";
|
|
}
|
|
const issueId = issue.id;
|
|
spinner?.stop();
|
|
console.log(issue.url);
|
|
|
|
if (start) {
|
|
await doStartIssue(issueId, issue.team.key);
|
|
}
|
|
} catch (error) {
|
|
spinner?.stop();
|
|
console.error("✗ Failed to create issue", error);
|
|
Deno.exit(1);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Add create command to issueCommand
|
|
issueCommand.command("create", createCommand);
|
|
|
|
const configQuery = gql(`
|
|
query Config {
|
|
viewer {
|
|
organization {
|
|
urlKey
|
|
}
|
|
}
|
|
teams {
|
|
nodes {
|
|
id
|
|
key
|
|
name
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
await new Command()
|
|
.name("linear")
|
|
.version(denoConfig.version)
|
|
.description("Handy linear commands from the command line")
|
|
.action(() => {
|
|
console.log("Use --help to see available commands");
|
|
})
|
|
.command("issue", issueCommand)
|
|
.alias("i")
|
|
.command("team", teamCommand)
|
|
.alias("t")
|
|
.command("completions", new CompletionsCommand())
|
|
.command("config", "Interactively generate .linear.toml configuration")
|
|
.action(async () => {
|
|
const apiKey = Deno.env.get("LINEAR_API_KEY");
|
|
if (!apiKey) {
|
|
console.error("The LINEAR_API_KEY environment variable is required.");
|
|
console.error(
|
|
"Create an API key at https://linear.app/settings/account/security",
|
|
);
|
|
console.error("For bash/zsh, run: export LINEAR_API_KEY=your_key");
|
|
console.error("For fish, run: set -gx LINEAR_API_KEY your_key");
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const client = getGraphQLClient();
|
|
const result = await client.request(configQuery);
|
|
const workspace = result.viewer.organization.urlKey;
|
|
const teams = result.teams.nodes;
|
|
// Sort teams alphabetically by name (case insensitive)
|
|
teams.sort((a, b) =>
|
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
);
|
|
|
|
interface Team {
|
|
id: string;
|
|
key: string;
|
|
name: string;
|
|
}
|
|
|
|
const selectedTeamId = await Select.prompt({
|
|
message: "Select a team:",
|
|
search: true,
|
|
searchLabel: "Search teams",
|
|
options: teams.map((team) => ({
|
|
name: `${team.name} (${team.key})`,
|
|
value: team.id,
|
|
})),
|
|
});
|
|
|
|
const team = teams.find((t) => t.id === selectedTeamId);
|
|
|
|
if (!team) {
|
|
console.error(`Could not find team: ${selectedTeamId}`);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const responses = await prompt([
|
|
{
|
|
name: "sort",
|
|
message: "Select sort order:",
|
|
type: Select,
|
|
options: [
|
|
{ name: "manual", value: "manual" },
|
|
{ name: "priority", value: "priority" },
|
|
],
|
|
},
|
|
]);
|
|
const teamKey = team.key;
|
|
const sortChoice = responses.sort;
|
|
|
|
// Determine file path for .linear.toml: prefer git root .config dir, then git root, then cwd.
|
|
let filePath: string;
|
|
try {
|
|
const gitRootProcess = await new Deno.Command("git", {
|
|
args: ["rev-parse", "--show-toplevel"],
|
|
}).output();
|
|
const gitRoot = new TextDecoder().decode(gitRootProcess.stdout).trim();
|
|
const { join } = await import("@std/path");
|
|
const configDir = join(gitRoot, ".config");
|
|
try {
|
|
await Deno.stat(configDir);
|
|
filePath = join(configDir, "linear.toml");
|
|
} catch {
|
|
filePath = join(gitRoot, ".linear.toml");
|
|
}
|
|
} catch {
|
|
filePath = "./.linear.toml";
|
|
}
|
|
|
|
const tomlContent = `# linear cli
|
|
# https://github.com/schpet/linear-cli
|
|
|
|
workspace = "${workspace}"
|
|
team_id = "${teamKey}"
|
|
issue_sort = "${sortChoice}"
|
|
`;
|
|
|
|
await Deno.writeTextFile(filePath, tomlContent);
|
|
console.log("Configuration written to", filePath);
|
|
})
|
|
.parse(Deno.args);
|