diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e188e60..a2b9b9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: run: deno lint - name: Type check - run: deno check --all + run: deno task check - name: Run tests run: deno task test diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a5f1b..41b076f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- add issue commits command to print previous commits associated with an issue + ## [1.4.0] - 2025-12-08 ### Added diff --git a/README.md b/README.md index c86441f..24e6471 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ deno task install the CLI works with both git and jj version control systems: - **git**: works best when your branches include Linear issue IDs (e.g. `eng-123-my-feature`). use `linear issue start` or linear UI's 'copy git branch name' button and [related automations](https://linear.app/docs/account-preferences#git-related-automations). -- **jj**: detects issues from `Linear-issue` trailers in your commit descriptions. use `linear issue start` to automatically add the trailer, or add it manually with `jj describe`. +- **jj**: detects issues from `Linear-issue` trailers in your commit descriptions. use `linear issue start` to automatically add the trailer, or add it manually with `jj describe`, e.g. `jj describe "$(linear issue describe ABC-123)"` ## commands @@ -100,7 +100,9 @@ the current issue is determined by: note that [Linear's GitHub integration](https://linear.app/docs/github#branch-format) will suggest git branch names. ```bash -linear issue view # view issue details in terminal +linear issue view # view current issue details in terminal +linear issue view ABC-123 +linear issue view 123 linear issue view -w # open issue in web browser linear issue view -a # open issue in Linear.app linear issue id # prints the issue id from current branch (e.g., "ENG-123") @@ -119,6 +121,7 @@ linear issue comment list # list comments on current issue linear issue comment add # add a comment to current issue linear issue comment add -p # reply to a specific comment linear issue comment update # update a comment +linear issue commits # show all commits for an issue (jj only) ``` ### team commands diff --git a/src/commands/issue/issue-commits.ts b/src/commands/issue/issue-commits.ts new file mode 100644 index 0000000..fcec353 --- /dev/null +++ b/src/commands/issue/issue-commits.ts @@ -0,0 +1,75 @@ +import { Command } from "@cliffy/command" +import { isClientError, logClientError } from "../../utils/graphql.ts" +import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts" +import { getNoIssueFoundMessage, getVcs } from "../../utils/vcs.ts" + +export const commitsCommand = new Command() + .name("commits") + .description("Show all commits for a Linear issue (jj only)") + .arguments("[issueId:string]") + .action(async (_options, issueId) => { + const vcs = getVcs() + + if (vcs !== "jj") { + console.error("✗ commits is only supported with jj-vcs") + Deno.exit(1) + } + + const resolvedId = await getIssueIdentifier(issueId) + if (!resolvedId) { + console.error(getNoIssueFoundMessage()) + Deno.exit(1) + } + + // Verify the issue exists in Linear + let linearIssueId: string | undefined + try { + linearIssueId = await getIssueId(resolvedId) + } catch (error) { + if (isClientError(error)) { + logClientError(error) + Deno.exit(1) + } + throw error + } + if (!linearIssueId) { + console.error(`✗ issue not found: ${resolvedId}`) + Deno.exit(1) + } + + // Build the revset to find all commits with this Linear issue + const revset = `description(regex:"(?m)^Linear-issue:.*${resolvedId}")` + + // First check if any commits exist + const checkProcess = new Deno.Command("jj", { + args: ["log", "-r", revset, "-T", "commit_id", "--no-graph"], + stdout: "piped", + stderr: "piped", + }) + const checkResult = await checkProcess.output() + const commitIds = new TextDecoder().decode(checkResult.stdout).trim() + + if (!commitIds) { + console.error(`✗ no commits found for ${resolvedId}`) + Deno.exit(1) + } + + // Show the commits with full details + const process = new Deno.Command("jj", { + args: [ + "log", + "-r", + revset, + "-p", + "--git", + "--no-graph", + "-T", + "builtin_log_compact_full_description", + ], + stdout: "inherit", + stderr: "inherit", + }) + + const { code } = await process.output() + Deno.exit(code) + }) diff --git a/src/commands/issue/issue-view.ts b/src/commands/issue/issue-view.ts index 142bc25..7b49ca8 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -16,7 +16,7 @@ import { unified } from "unified" import remarkParse from "remark-parse" import remarkStringify from "remark-stringify" import { visit } from "unist-util-visit" -import type { Image } from "mdast" +import type { Image, Root } from "mdast" import { shouldEnableHyperlinks } from "../../utils/hyperlink.ts" import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts" @@ -347,7 +347,7 @@ export async function replaceImageUrls( ): Promise { const processor = unified() .use(remarkParse) - .use(() => (tree) => { + .use(() => (tree: Root) => { visit(tree, "image", (node: Image) => { const localPath = urlToPath.get(node.url) if (localPath) { diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index 6f2e919..477a4cd 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -3,6 +3,7 @@ import { commentCommand } from "./issue-comment.ts" import { createCommand } from "./issue-create.ts" import { deleteCommand } from "./issue-delete.ts" import { describeCommand } from "./issue-describe.ts" +import { commitsCommand } from "./issue-commits.ts" import { idCommand } from "./issue-id.ts" import { listCommand } from "./issue-list.ts" import { pullRequestCommand } from "./issue-pull-request.ts" @@ -24,6 +25,7 @@ export const issueCommand = new Command() .command("view", viewCommand) .command("url", urlCommand) .command("describe", describeCommand) + .command("commits", commitsCommand) .command("pull-request", pullRequestCommand) .command("delete", deleteCommand) .command("create", createCommand) diff --git a/src/utils/graphql.ts b/src/utils/graphql.ts index f6ed885..bd01b20 100644 --- a/src/utils/graphql.ts +++ b/src/utils/graphql.ts @@ -1,6 +1,39 @@ -import { GraphQLClient } from "graphql-request" +import { ClientError, GraphQLClient } from "graphql-request" +import { gray, setColorEnabled } from "@std/fmt/colors" import { getOption } from "../config.ts" +export { ClientError } + +/** + * Checks if an error is a GraphQL ClientError + */ +export function isClientError(error: unknown): error is ClientError { + return error instanceof ClientError +} + +/** + * Logs a GraphQL ClientError formatted for display to the user + */ +export function logClientError(error: ClientError): void { + const userMessage = error.response?.errors?.[0]?.extensions + ?.userPresentableMessage as + | string + | undefined + const message = userMessage?.toLowerCase() ?? error.message + + console.error(`✗ ${message}\n`) + + const rawQuery = error.request?.query + const query = typeof rawQuery === "string" ? rawQuery.trim() : rawQuery + const vars = JSON.stringify(error.request?.variables, null, 2) + + setColorEnabled(Deno.stderr.isTerminal()) + + console.error(gray(String(query))) + console.error("") + console.error(gray(vars)) +} + export function getGraphQLClient(): GraphQLClient { const apiKey = getOption("api_key") if (!apiKey) { diff --git a/test/commands/issue/__snapshots__/issue-commits.test.ts.snap b/test/commands/issue/__snapshots__/issue-commits.test.ts.snap new file mode 100644 index 0000000..b95864c --- /dev/null +++ b/test/commands/issue/__snapshots__/issue-commits.test.ts.snap @@ -0,0 +1,19 @@ +export const snapshot = {}; + +snapshot[`Issue Commits Command - Help Text 1`] = ` +stdout: +" +Usage: commits [issueId] + +Description: + + Show all commits for a Linear issue (jj only) + +Options: + + -h, --help - Show this help. + +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-commits.test.ts b/test/commands/issue/issue-commits.test.ts new file mode 100644 index 0000000..1e52159 --- /dev/null +++ b/test/commands/issue/issue-commits.test.ts @@ -0,0 +1,24 @@ +import { snapshotTest } from "@cliffy/testing" +import { commitsCommand } from "../../../src/commands/issue/issue-commits.ts" + +// Common Deno args for permissions +const denoArgs = [ + "--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-net", + "--quiet", +] + +// Test help output +await snapshotTest({ + name: "Issue Commits Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await commitsCommand.parse() + }, +})