Files
schpet__linear-cli/main.ts
T
2025-01-15 07:30:14 -08:00

301 lines
8.3 KiB
TypeScript

import { Command } from "@cliffy/command";
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 } from "@std/path";
async function getCurrentBranch(): Promise<string> {
const process = new Deno.Command("git", {
args: ["symbolic-ref", "--short", "HEAD"],
});
const { stdout } = await process.output();
return new TextDecoder().decode(stdout).trim();
}
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 getIssueId(): Promise<string | null> {
const branch = await getCurrentBranch();
const match = branch.match(/[a-zA-Z]{2,5}-[1-9][0-9]*/i);
return match ? match[0].toUpperCase() : null;
}
async function getTeamId(): Promise<string | null> {
const dir = await getRepoDir();
const match = dir.match(/^[a-zA-Z]{2,5}/);
return match ? match[0].toUpperCase() : null;
}
async function fetchGraphQL(query: string, variables: Record<string, unknown>) {
const apiKey = Deno.env.get("LINEAR_API_KEY");
if (!apiKey) {
throw new Error("LINEAR_API_KEY environment variable is not set.");
}
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": apiKey,
},
body: JSON.stringify({ query, variables }),
});
const data = await response.json();
if (data.errors) {
throw new Error(JSON.stringify(data.errors, null, 2));
}
return data;
}
async function fetchIssueDetails(
issueId: string,
showSpinner = false,
): Promise<{ title: string; description: string | null; url: string }> {
const spinner = showSpinner ? new Spinner() : null;
spinner?.start();
try {
const query =
`query($id: String!) { issue(id: $id) { title, description, url } }`;
const data = await fetchGraphQL(query, { id: issueId });
spinner?.stop();
return data.data.issue;
} catch (error) {
spinner?.stop();
console.error("✗ Failed to fetch issue details");
throw error;
}
}
async function openTeamPage() {
const teamId = await getTeamId();
if (!teamId) {
console.error("Could not determine team id from directory name.");
Deno.exit(1);
}
const workspace = Deno.env.get("LINEAR_WORKSPACE");
if (!workspace) {
console.error("LINEAR_WORKSPACE environment variable is not set.");
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, { app: { name: "Linear" } });
}
async function openIssuePage() {
const issueId = await getIssueId();
if (!issueId) {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
const workspace = Deno.env.get("LINEAR_WORKSPACE");
if (!workspace) {
console.error("LINEAR_WORKSPACE environment variable is not set.");
Deno.exit(1);
}
const url = `https://linear.app/${workspace}/issue/${issueId}`;
console.log(`Opening ${url} in Linear.app`);
await open(url, { app: { name: "Linear" } });
}
const teamCommand = new Command()
.description("Manage Linear teams")
.action(openTeamPage)
.command("open", "Open the team page in Linear.app")
.alias("o")
.action(openTeamPage)
.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 = Deno.env.get("LINEAR_WORKSPACE");
if (!workspace) {
console.error("LINEAR_WORKSPACE environment variable is not set.");
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")
.action(openIssuePage)
.command("open", "Open the issue in Linear.app")
.alias("o")
.action(openIssuePage)
.command("print", "Print the issue details")
.alias("p")
.option("--no-color", "Disable colored output")
.action(async ({ color }) => {
const issueId = await getIssueId();
if (!issueId) {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
const showSpinner = color && Deno.stdout.isTerminal();
const { title, description } = await fetchIssueDetails(
issueId,
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 id in the current git branch")
.action(async () => {
const issueId = await getIssueId();
if (issueId) {
console.log(issueId);
} else {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
})
.command("title", "Print the issue title")
.action(async () => {
const issueId = await getIssueId();
if (!issueId) {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
const { title } = await fetchIssueDetails(issueId, false);
console.log(title);
})
.command("url", "Print the issue URL")
.action(async () => {
const issueId = await getIssueId();
if (!issueId) {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
const { url } = await fetchIssueDetails(issueId, 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",
)
.action(async ({ base, draft }) => {
const issueId = await getIssueId();
if (!issueId) {
console.error(
"The current branch does not contain a valid linear issue id.",
);
Deno.exit(1);
}
const { title, url } = await fetchIssueDetails(
issueId,
Deno.stdout.isTerminal(),
);
const process = new Deno.Command("gh", {
args: [
"pr",
"create",
"--title",
`${issueId} ${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);
}
});
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())
.parse(Deno.args);