From a1741df59ce9f490ff9660e13311eb1822d6072a Mon Sep 17 00:00:00 2001 From: Alexander Girardet Date: Thu, 26 Mar 2026 15:29:23 +0000 Subject: [PATCH] feat: add Alavida PR body workflow --- README.md | 30 +++---- deno.json | 2 +- dist-workspace.toml | 12 ++- justfile | 2 +- skills/linear-cli/SKILL.md | 9 +- skills/linear-cli/SKILL.template.md | 8 +- src/commands/api.ts | 2 +- src/commands/config.ts | 2 +- src/commands/pr-body.ts | 128 ++++++++++++++++++++++++++++ src/main.ts | 2 + src/utils/graphql.ts | 2 +- test/commands/pr-body.test.ts | 101 ++++++++++++++++++++++ test/utils/mock_linear_server.ts | 5 +- 13 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 src/commands/pr-body.ts create mode 100644 test/commands/pr-body.test.ts diff --git a/README.md b/README.md index dbda35c1..0bac8016 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ linear issue start ABC-123 # start a specific issue linear issue view # see current branch's issue as markdown linear issue pr # makes a PR with title/body preset, using gh cli linear issue create # create a new issue +linear pr-body --issues ALA-123,ALA-124 # generate the Alavida PR template ``` it aims to be a complement to the web and desktop apps that lets you stay on the command line in an interactive or scripted way. @@ -38,16 +39,10 @@ it aims to be a complement to the web and desktop apps that lets you stay on the ## install -### homebrew - -``` -brew install schpet/tap/linear -``` - ### deno via jsr ```bash -deno install -A --reload -f -g -n linear jsr:@schpet/linear-cli +deno install -A --reload -f -g -n linear jsr:@alavida/linear-cli ``` ### npm / bun / pnpm @@ -55,11 +50,11 @@ deno install -A --reload -f -g -n linear jsr:@schpet/linear-cli install as a dev dependency to pin a version in your project: ```bash -npm install -D @schpet/linear-cli +npm install -D @alavida/linear-cli # or -bun add -D @schpet/linear-cli +bun add -D @alavida/linear-cli # or -pnpm add -D @schpet/linear-cli +pnpm add -D @alavida/linear-cli ``` then run via your package manager: @@ -71,16 +66,16 @@ bunx linear issue list > **note:** this package ships pre-built binaries -package on npm: [@schpet/linear-cli](https://www.npmjs.com/package/@schpet/linear-cli) +package on npm: `@alavida/linear-cli` ### binaries -https://github.com/schpet/linear-cli/releases/latest +https://github.com/alavida-ai/linear-cli/releases/latest ### local dev ```bash -git clone https://github.com/schpet/linear-cli +git clone https://github.com/alavida-ai/linear-cli cd linear-cli deno task install ``` @@ -163,6 +158,7 @@ linear team autolinks # configure GitHub repository autolinks for Linear issues ```bash linear project list # list projects linear project view # view project details +linear project create --name "Client Deliverable" --team ENG --initiative "Client Delivery" ``` ### milestone commands @@ -255,11 +251,11 @@ install the skill using [claude code's plugin system](https://code.claude.com/do ```bash # from claude code -/plugin marketplace add schpet/linear-cli +/plugin marketplace add alavida-ai/linear-cli /plugin install linear-cli@linear-cli # from bash -claude plugin marketplace add schpet/linear-cli +claude plugin marketplace add alavida-ai/linear-cli claude plugin install linear-cli@linear-cli # to update @@ -272,10 +268,10 @@ claude plugin update linear-cli@linear-cli install the skill using [skills.sh](https://skills.sh): ```bash -npx skills add schpet/linear-cli +npx skills add alavida-ai/linear-cli ``` -view the skill at [skills.sh/schpet/linear-cli/linear-cli](https://skills.sh/schpet/linear-cli/linear-cli) +view the skill at [skills.sh/alavida-ai/linear-cli/linear-cli](https://skills.sh/alavida-ai/linear-cli/linear-cli) ## development diff --git a/deno.json b/deno.json index fa63109a..fedc36d2 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json", - "name": "@schpet/linear-cli", + "name": "@alavida/linear-cli", "version": "1.11.1", "exports": "./src/main.ts", "license": "MIT", diff --git a/dist-workspace.toml b/dist-workspace.toml index 395de68c..870e2e7d 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -7,8 +7,8 @@ name = "linear" description = "CLI tool for linear.app that uses git branch names and directory names to open issues and team pages" version = "1.11.1" license = "MIT" -repository = "https://github.com/schpet/linear-cli" -homepage = "https://github.com/schpet/linear-cli" +repository = "https://github.com/alavida-ai/linear-cli" +homepage = "https://github.com/alavida-ai/linear-cli" binaries = ["linear"] build-command = [ "sh", @@ -23,15 +23,13 @@ cargo-dist-version = "0.31.0" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "npm", "homebrew"] -# A GitHub repo to push Homebrew formulas to -tap = "schpet/homebrew-tap" +installers = ["shell", "npm"] # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # Publish jobs to run in CI -publish-jobs = ["homebrew", "npm"] +publish-jobs = ["npm"] # A namespace to use when publishing this package to the npm registry -npm-scope = "@schpet" +npm-scope = "@alavida" # The npm package should have this name npm-package = "linear-cli" # Which actions to run on pull requests diff --git a/justfile b/justfile index 6580309a..22db7815 100644 --- a/justfile +++ b/justfile @@ -34,5 +34,5 @@ claude-install-local: claude plugin install linear-cli@linear-cli claude-install-github: - claude plugin marketplace add schpet/linear-cli + claude plugin marketplace add alavida-ai/linear-cli claude plugin install linear-cli@linear-cli diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index cd4c181d..b49f1e17 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -8,6 +8,8 @@ allowed-tools: Bash(linear:*), Bash(curl:*) A CLI to manage Linear issues from the command line, with git and jj integration. +Use the `linear` CLI directly. The `allowed-tools` metadata only grants execution permission for the installed CLI; it is not a separate wrapper interface. + ## Prerequisites The `linear` command must be available on PATH. To check: @@ -19,11 +21,11 @@ linear --version If not installed globally, you can run it without installing via npx: ```bash -npx @schpet/linear-cli --version +npx @alavida/linear-cli --version ``` -All subsequent commands can be prefixed with `npx @schpet/linear-cli` in place of `linear`. Otherwise, follow the install instructions at:\ -https://github.com/schpet/linear-cli?tab=readme-ov-file#install +All subsequent commands can be prefixed with `npx @alavida/linear-cli` in place of `linear`. Otherwise, follow the install instructions at:\ +https://github.com/alavida-ai/linear-cli?tab=readme-ov-file#install ## Best Practices for Markdown Content @@ -80,6 +82,7 @@ linear document # Manage Linear documents linear config # Interactively generate .linear.toml configuration linear schema # Print the GraphQL schema to stdout linear api # Make a raw GraphQL API request +linear pr-body # Generate the Alavida PR template from one or more issues ``` ## Reference Documentation diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index 8d733671..129257e6 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -8,6 +8,8 @@ allowed-tools: Bash(linear:*), Bash(curl:*) A CLI to manage Linear issues from the command line, with git and jj integration. +Use the `linear` CLI directly. The `allowed-tools` metadata only grants execution permission for the installed CLI; it is not a separate wrapper interface. + ## Prerequisites The `linear` command must be available on PATH. To check: @@ -19,11 +21,11 @@ linear --version If not installed globally, you can run it without installing via npx: ```bash -npx @schpet/linear-cli --version +npx @alavida/linear-cli --version ``` -All subsequent commands can be prefixed with `npx @schpet/linear-cli` in place of `linear`. Otherwise, follow the install instructions at:\ -https://github.com/schpet/linear-cli?tab=readme-ov-file#install +All subsequent commands can be prefixed with `npx @alavida/linear-cli` in place of `linear`. Otherwise, follow the install instructions at:\ +https://github.com/alavida-ai/linear-cli?tab=readme-ov-file#install ## Best Practices for Markdown Content diff --git a/src/commands/api.ts b/src/commands/api.ts index 3d6f2eae..59b35e8e 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -68,7 +68,7 @@ export const apiCommand = new Command() const headers = { "Content-Type": "application/json", Authorization: apiKey, - "User-Agent": `schpet-linear-cli/${denoConfig.version}`, + "User-Agent": `alavida-linear-cli/${denoConfig.version}`, } if (options.paginate) { diff --git a/src/commands/config.ts b/src/commands/config.ts index d29b829a..e2501060 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -132,7 +132,7 @@ export const configCommand = new Command() } const tomlContent = `# linear cli -# https://github.com/schpet/linear-cli +# https://github.com/alavida-ai/linear-cli workspace = "${workspace}" team_id = "${teamKey}" diff --git a/src/commands/pr-body.ts b/src/commands/pr-body.ts new file mode 100644 index 00000000..bbae6100 --- /dev/null +++ b/src/commands/pr-body.ts @@ -0,0 +1,128 @@ +import { Command } from "@cliffy/command" +import { gql } from "../__codegen__/gql.ts" +import { getGraphQLClient } from "../utils/graphql.ts" +import { handleError, ValidationError } from "../utils/errors.ts" + +const GetIssuesForPrBody = gql(` + query GetIssuesForPrBody($ids: [ID!]) { + issues(filter: { id: { in: $ids } }) { + nodes { + identifier + title + labels { + nodes { + name + } + } + } + } + } +`) + +const WORK_TYPE_LABELS = [ + "Unplanned", + "Change", + "Business", + "Internal", +] as const + +type WorkType = (typeof WORK_TYPE_LABELS)[number] + +function parseIssueIdentifiers(raw: string): string[] { + const ids = raw + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + + if (ids.length === 0) { + throw new ValidationError("At least one issue ID is required", { + suggestion: "Pass a comma-separated list, e.g. --issues ALA-123,ALA-124", + }) + } + + return ids +} + +function inferWorkType( + issues: Array<{ labels?: { nodes?: Array<{ name: string }> } | null }>, +): WorkType { + for (const label of WORK_TYPE_LABELS) { + if ( + issues.some((issue) => + issue.labels?.nodes?.some((issueLabel) => issueLabel.name === label) + ) + ) { + return label + } + } + + return "Internal" +} + +function renderPrBody( + issues: Array<{ identifier: string; title: string }>, + workType: WorkType, +): string { + const ticketLines = issues.map((issue) => + `- ${issue.identifier} — ${issue.title}` + ).join("\n") + + return `## What this does +[Describe what changed in plain English — one paragraph, no jargon] + +## Tickets addressed +${ticketLines} + +## Work type +${workType} + +## Review checklist +- [ ] Changes reviewed +- [ ] Ready to merge` +} + +export const prBodyCommand = new Command() + .name("pr-body") + .description("Generate an Alavida PR body from Linear issues") + .option( + "--issues ", + "Comma-separated issue identifiers, e.g. ALA-123,ALA-124", + { required: true }, + ) + .action(async ({ issues: rawIssues }) => { + try { + const issueIds = parseIssueIdentifiers(rawIssues) + const client = getGraphQLClient() + const result = await client.request(GetIssuesForPrBody, { ids: issueIds }) + const issues = result.issues?.nodes || [] + + const issuesById = new Map( + issues.map((issue) => [issue.identifier, issue] as const), + ) + const orderedIssues = issueIds.map((issueId) => issuesById.get(issueId)) + const missingIssues = issueIds.filter((issueId) => + !issuesById.has(issueId) + ) + + if (missingIssues.length > 0) { + throw new ValidationError( + `Issue not found: ${missingIssues.join(", ")}`, + { + suggestion: + "Check the issue identifiers and make sure they are accessible in the current Linear workspace.", + }, + ) + } + + console.log( + renderPrBody( + orderedIssues.filter((issue): issue is NonNullable => + Boolean(issue) + ), + inferWorkType(issues), + ), + ) + } catch (error) { + handleError(error, "Failed to generate PR body") + } + }) diff --git a/src/main.ts b/src/main.ts index ecb9d13c..ab09ff8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { documentCommand } from "./commands/document/document.ts" import { configCommand } from "./commands/config.ts" import { schemaCommand } from "./commands/schema.ts" import { apiCommand } from "./commands/api.ts" +import { prBodyCommand } from "./commands/pr-body.ts" import { setCliWorkspace } from "./config.ts" // Import config and credentials setup @@ -64,4 +65,5 @@ Environment Variables: .command("config", configCommand) .command("schema", schemaCommand) .command("api", apiCommand) + .command("pr-body", prBodyCommand) .parse(Deno.args) diff --git a/src/utils/graphql.ts b/src/utils/graphql.ts index 9338cf63..6c9b22e1 100644 --- a/src/utils/graphql.ts +++ b/src/utils/graphql.ts @@ -101,7 +101,7 @@ export function createGraphQLClient(apiKey: string): GraphQLClient { return new GraphQLClient(getGraphQLEndpoint(), { headers: { Authorization: apiKey, - "User-Agent": `schpet-linear-cli/${denoConfig.version}`, + "User-Agent": `alavida-linear-cli/${denoConfig.version}`, }, }) } diff --git a/test/commands/pr-body.test.ts b/test/commands/pr-body.test.ts new file mode 100644 index 00000000..bd3b5f09 --- /dev/null +++ b/test/commands/pr-body.test.ts @@ -0,0 +1,101 @@ +import { assertEquals, assertStringIncludes } from "@std/assert" +import { MockLinearServer } from "../utils/mock_linear_server.ts" + +const repoRoot = + "/Users/alexandergirardet/.superset/worktrees/Alavida/_external/linear-cli-ala493" + +Deno.test("pr-body command help is available from the main CLI", async () => { + const command = new Deno.Command("npx", { + args: [ + "--yes", + "deno", + "run", + "--allow-all", + "--quiet", + "src/main.ts", + "pr-body", + "--help", + ], + cwd: repoRoot, + stdout: "piped", + stderr: "piped", + }) + + const output = await command.output() + const stdout = new TextDecoder().decode(output.stdout) + const stderr = new TextDecoder().decode(output.stderr) + + assertEquals(output.code, 0, stderr) + assertStringIncludes(stdout, "Generate an Alavida PR body") + assertStringIncludes(stdout, "--issues") +}) + +Deno.test("pr-body command renders the Alavida PR template", async () => { + const port = 40000 + Math.floor(Math.random() * 10000) + const server = new MockLinearServer([ + { + queryName: "GetIssuesForPrBody", + variables: { ids: ["ALA-123", "ALA-124"] }, + response: { + data: { + issues: { + nodes: [ + { + identifier: "ALA-123", + title: "Add initiative workflow", + labels: { nodes: [{ name: "Internal" }] }, + }, + { + identifier: "ALA-124", + title: "Wire PR body into ops management", + labels: { nodes: [{ name: "Change" }] }, + }, + ], + }, + }, + }, + }, + ], port) + + try { + await server.start() + + const command = new Deno.Command("npx", { + args: [ + "--yes", + "deno", + "run", + "--allow-all", + "--quiet", + "src/main.ts", + "pr-body", + "--issues", + "ALA-123,ALA-124", + ], + cwd: repoRoot, + env: { + LINEAR_GRAPHQL_ENDPOINT: server.getEndpoint(), + LINEAR_API_KEY: "Bearer test-token", + }, + stdout: "piped", + stderr: "piped", + }) + + const output = await command.output() + const stdout = new TextDecoder().decode(output.stdout) + const stderr = new TextDecoder().decode(output.stderr) + + assertEquals(output.code, 0, stderr) + assertStringIncludes(stdout, "## What this does") + assertStringIncludes(stdout, "- ALA-123 — Add initiative workflow") + assertStringIncludes( + stdout, + "- ALA-124 — Wire PR body into ops management", + ) + assertStringIncludes(stdout, "## Work type") + assertStringIncludes(stdout, "Change") + assertStringIncludes(stdout, "## Review checklist") + } finally { + await server.stop() + } +}) diff --git a/test/utils/mock_linear_server.ts b/test/utils/mock_linear_server.ts index 025036bd..ef2aa664 100644 --- a/test/utils/mock_linear_server.ts +++ b/test/utils/mock_linear_server.ts @@ -20,11 +20,12 @@ interface MockResponse { export class MockLinearServer { private server?: Deno.HttpServer - private port = 3333 + private port: number private mockResponses: MockResponse[] - constructor(responses: MockResponse[] = []) { + constructor(responses: MockResponse[] = [], port = 3333) { this.mockResponses = responses + this.port = port } async start(): Promise {