From 52798018052615dbb6c913db6dd18cc2dcda1dab Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 2 Apr 2026 09:14:58 -0700 Subject: [PATCH] fix: accept alphanumeric Linear issue team keys --- src/commands/issue/issue-update.ts | 4 +- src/utils/issue-identifier.ts | 65 ++++++++++ src/utils/jj.ts | 16 +-- src/utils/linear.ts | 29 ++--- src/utils/vcs.ts | 15 +-- .../__snapshots__/issue-update.test.ts.snap | 11 ++ test/commands/issue/issue-update.test.ts | 52 ++++++++ test/utils/issue-identifier.test.ts | 118 ++++++++++++++++++ test/utils/jj.test.ts | 7 ++ 9 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 src/utils/issue-identifier.ts create mode 100644 test/utils/issue-identifier.test.ts diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 1487516f..f86e2e1c 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -1,6 +1,7 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" +import { getTeamKeyFromIssueIdentifier } from "../../utils/issue-identifier.ts" import { getCycleIdByNameOrNumber, getIssueId, @@ -143,8 +144,7 @@ export const updateCommand = new Command() // Extract team from issue ID if not provided let teamKey = team if (!teamKey) { - const match = issueId.match(/^([A-Z]+)-/) - teamKey = match?.[1] + teamKey = getTeamKeyFromIssueIdentifier(issueId) } if (!teamKey) { throw new ValidationError( diff --git a/src/utils/issue-identifier.ts b/src/utils/issue-identifier.ts new file mode 100644 index 00000000..c1de6bcf --- /dev/null +++ b/src/utils/issue-identifier.ts @@ -0,0 +1,65 @@ +const LINEAR_IDENTIFIER_RE = /^([a-zA-Z0-9]+)-([1-9][0-9]*)$/ +const LINEAR_IDENTIFIER_IN_TEXT_RE = /\b([a-zA-Z0-9]+)-([1-9][0-9]*)\b/ + +export interface ParsedIssueIdentifier { + identifier: string + teamKey: string + issueNumber: string +} + +function buildParsedIssueIdentifier( + teamKey: string, + issueNumber: string, +): ParsedIssueIdentifier { + const normalizedTeamKey = teamKey.toUpperCase() + + return { + identifier: `${normalizedTeamKey}-${issueNumber}`, + teamKey: normalizedTeamKey, + issueNumber, + } +} + +export function parseIssueIdentifier( + value: string, +): ParsedIssueIdentifier | undefined { + const match = value.match(LINEAR_IDENTIFIER_RE) + if (!match) { + return undefined + } + + const teamKey = match[1] + const issueNumber = match[2] + if (teamKey == null || issueNumber == null) { + return undefined + } + + return buildParsedIssueIdentifier(teamKey, issueNumber) +} + +export function findIssueIdentifierInText( + value: string, +): ParsedIssueIdentifier | undefined { + const match = value.match(LINEAR_IDENTIFIER_IN_TEXT_RE) + if (!match) { + return undefined + } + + const teamKey = match[1] + const issueNumber = match[2] + if (teamKey == null || issueNumber == null) { + return undefined + } + + return buildParsedIssueIdentifier(teamKey, issueNumber) +} + +export function getTeamKeyFromIssueIdentifier( + value: string, +): string | undefined { + return parseIssueIdentifier(value)?.teamKey +} + +export function normalizeIssueIdentifier(value: string): string | undefined { + return parseIssueIdentifier(value)?.identifier +} diff --git a/src/utils/jj.ts b/src/utils/jj.ts index b9d500c7..f1769671 100644 --- a/src/utils/jj.ts +++ b/src/utils/jj.ts @@ -1,3 +1,5 @@ +import { findIssueIdentifierInText } from "./issue-identifier.ts" + /** * Utilities for jj (Jujutsu) version control system */ @@ -92,19 +94,7 @@ export async function createJjNewChange(): Promise { export function parseLinearIssueFromTrailer( trailerValue: string, ): string | null { - // Try new format first: "MagicWord TEAM-123" where issue number doesn't start with 0 - const newFormatMatch = trailerValue.match(/\b([A-Z]+-[1-9]\d*)\b/i) - if (newFormatMatch && newFormatMatch[1]) { - return newFormatMatch[1].toUpperCase() - } - - // Fall back to old format: [TEAM-123](...) - const oldFormatMatch = trailerValue.match(/\[([A-Z]+-[1-9]\d*)\]/i) - if (oldFormatMatch && oldFormatMatch[1]) { - return oldFormatMatch[1].toUpperCase() - } - - return null + return findIssueIdentifierInText(trailerValue)?.identifier ?? null } /** diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 37bbe691..b4efa0de 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -13,9 +13,10 @@ import type { } from "../__codegen__/graphql.ts" import { Select } from "@cliffy/prompt" import { getOption } from "../config.ts" +import { NotFoundError, ValidationError } from "./errors.ts" import { getGraphQLClient } from "./graphql.ts" +import { normalizeIssueIdentifier } from "./issue-identifier.ts" import { getCurrentIssueFromVcs } from "./vcs.ts" -import { NotFoundError, ValidationError } from "./errors.ts" /** * Validate and parse a date string in ISO 8601 format (YYYY-MM-DD or full ISO 8601). @@ -45,12 +46,8 @@ export function parseDateFilter(value: string, flagName: string): string { return parsed.toISOString() } -function isValidLinearIdentifier(id: string): boolean { - return /^[a-zA-Z0-9]+-[1-9][0-9]*$/i.test(id) -} - export function formatIssueIdentifier(providedId: string): string { - return providedId.toUpperCase() + return normalizeIssueIdentifier(providedId) ?? providedId.toUpperCase() } export function getTeamKey(): string | undefined { @@ -69,22 +66,22 @@ export function getTeamKey(): string | undefined { export async function getIssueIdentifier( providedId?: string, ): Promise { - if (providedId && isValidLinearIdentifier(providedId)) { - return formatIssueIdentifier(providedId) + if (providedId) { + const normalizedIdentifier = normalizeIssueIdentifier(providedId) + if (normalizedIdentifier) { + return normalizedIdentifier + } } if (providedId && /^[1-9][0-9]*$/.test(providedId)) { const teamId = getTeamKey() if (teamId) { - const fullId = `${teamId}-${providedId}` - if (isValidLinearIdentifier(fullId)) { - return formatIssueIdentifier(fullId) - } - } else { - throw new Error( - "an integer id was provided, but no team is set. run `linear configure`", - ) + return normalizeIssueIdentifier(`${teamId}-${providedId}`) } + + throw new Error( + "an integer id was provided, but no team is set. run `linear configure`", + ) } if (providedId === undefined) { diff --git a/src/utils/vcs.ts b/src/utils/vcs.ts index adfd9f7b..0ec99e3f 100644 --- a/src/utils/vcs.ts +++ b/src/utils/vcs.ts @@ -1,4 +1,8 @@ +import { Select } from "@cliffy/prompt" import { getOption } from "../config.ts" +import { CliError } from "./errors.ts" +import { getCurrentBranch } from "./git.ts" +import { findIssueIdentifierInText } from "./issue-identifier.ts" import { fetchIssueDetails } from "./linear.ts" import { formatIssueDescription, @@ -6,9 +10,6 @@ import { prepareJjWorkingState, setJjDescription, } from "./jj.ts" -import { getCurrentBranch } from "./git.ts" -import { Select } from "@cliffy/prompt" -import { CliError } from "./errors.ts" export type VcsType = "git" | "jj" @@ -66,12 +67,8 @@ export async function getCurrentIssueFromVcs(): Promise { const branch = await getCurrentBranch() if (!branch) return null - // Extract issue ID from branch name (e.g., "feature/ABC-123-description" -> "ABC-123") - const match = branch.match(/[a-zA-Z0-9]+-[1-9][0-9]*/i) - if (match) { - return match[0].toUpperCase() - } - return null + const issueIdentifier = findIssueIdentifierInText(branch)?.identifier + return issueIdentifier ?? null } case "jj": { return await getJjLinearIssue() diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 74280470..67d8f315 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -43,6 +43,17 @@ stderr: "" `; +snapshot[`Issue Update Command - Alphanumeric Team Key 1`] = ` +stdout: +"Updating issue PLA4-16916 + +✓ Updated issue PLA4-16916: Test Issue +https://linear.app/test-team/issue/PLA4-16916/test-issue +" +stderr: +"" +`; + snapshot[`Issue Update Command - With Milestone 1`] = ` stdout: "Updating issue ENG-123 diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 275a18b4..b0969d85 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -90,6 +90,58 @@ await snapshotTest({ }, }) +// Test updating an issue with an alphanumeric team key +await snapshotTest({ + name: "Issue Update Command - Alphanumeric Team Key", + meta: import.meta, + colors: false, + args: [ + "PLA4-16916", + "--description", + "new description", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + // Mock response for getTeamIdByKey() - team keys may contain digits + { + queryName: "GetTeamIdByKey", + variables: { team: "PLA4" }, + response: { + data: { + teams: { + nodes: [{ id: "team-pla4-id" }], + }, + }, + }, + }, + // Mock response for the update issue mutation + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-pla4-16916", + identifier: "PLA4-16916", + url: "https://linear.app/test-team/issue/PLA4-16916/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ]) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + // Test updating an issue with milestone await snapshotTest({ name: "Issue Update Command - With Milestone", diff --git a/test/utils/issue-identifier.test.ts b/test/utils/issue-identifier.test.ts new file mode 100644 index 00000000..b5a9a763 --- /dev/null +++ b/test/utils/issue-identifier.test.ts @@ -0,0 +1,118 @@ +import { assertEquals } from "@std/assert" +import { + findIssueIdentifierInText, + getTeamKeyFromIssueIdentifier, + normalizeIssueIdentifier, + parseIssueIdentifier, +} from "../../src/utils/issue-identifier.ts" + +// parseIssueIdentifier + +Deno.test("parseIssueIdentifier - parses standard identifier", () => { + const result = parseIssueIdentifier("ABC-123") + assertEquals(result, { + identifier: "ABC-123", + teamKey: "ABC", + issueNumber: "123", + }) +}) + +Deno.test("parseIssueIdentifier - parses alphanumeric team key", () => { + const result = parseIssueIdentifier("PLA4-16916") + assertEquals(result, { + identifier: "PLA4-16916", + teamKey: "PLA4", + issueNumber: "16916", + }) +}) + +Deno.test("parseIssueIdentifier - normalizes team key to uppercase", () => { + const result = parseIssueIdentifier("abc-123") + assertEquals(result, { + identifier: "ABC-123", + teamKey: "ABC", + issueNumber: "123", + }) +}) + +Deno.test("parseIssueIdentifier - returns undefined for number starting with zero", () => { + assertEquals(parseIssueIdentifier("ABC-0123"), undefined) +}) + +Deno.test("parseIssueIdentifier - returns undefined for bare number", () => { + assertEquals(parseIssueIdentifier("123"), undefined) +}) + +Deno.test("parseIssueIdentifier - returns undefined for empty string", () => { + assertEquals(parseIssueIdentifier(""), undefined) +}) + +Deno.test("parseIssueIdentifier - returns undefined for text with identifier embedded", () => { + // parseIssueIdentifier requires exact match, not search + assertEquals(parseIssueIdentifier("Fixes ABC-123"), undefined) +}) + +// findIssueIdentifierInText + +Deno.test("findIssueIdentifierInText - finds identifier in bracket+url format", () => { + const result = findIssueIdentifierInText( + "[ABC-123](https://linear.app/workspace/issue/ABC-123/some-title)", + ) + assertEquals(result?.identifier, "ABC-123") +}) + +Deno.test("findIssueIdentifierInText - finds alphanumeric team key in bracket format", () => { + const result = findIssueIdentifierInText( + "[PLA4-16916](https://linear.app/workspace/issue/PLA4-16916/some-title)", + ) + assertEquals(result?.identifier, "PLA4-16916") +}) + +Deno.test("findIssueIdentifierInText - finds identifier in plain text", () => { + const result = findIssueIdentifierInText("Fixes ABC-123") + assertEquals(result?.identifier, "ABC-123") +}) + +Deno.test("findIssueIdentifierInText - finds identifier in branch name", () => { + const result = findIssueIdentifierInText("feature/ABC-123-my-feature") + assertEquals(result?.identifier, "ABC-123") +}) + +Deno.test("findIssueIdentifierInText - normalizes to uppercase", () => { + const result = findIssueIdentifierInText("[abc-456](https://linear.app/...)") + assertEquals(result?.identifier, "ABC-456") +}) + +Deno.test("findIssueIdentifierInText - returns undefined for empty string", () => { + assertEquals(findIssueIdentifierInText(""), undefined) +}) + +Deno.test("findIssueIdentifierInText - returns undefined when no identifier present", () => { + assertEquals(findIssueIdentifierInText("no issue here"), undefined) +}) + +// getTeamKeyFromIssueIdentifier + +Deno.test("getTeamKeyFromIssueIdentifier - extracts team key", () => { + assertEquals(getTeamKeyFromIssueIdentifier("ENG-42"), "ENG") +}) + +Deno.test("getTeamKeyFromIssueIdentifier - extracts alphanumeric team key", () => { + assertEquals(getTeamKeyFromIssueIdentifier("PLA4-16916"), "PLA4") +}) + +Deno.test("getTeamKeyFromIssueIdentifier - returns undefined for invalid input", () => { + assertEquals(getTeamKeyFromIssueIdentifier("not-an-issue"), undefined) + assertEquals(getTeamKeyFromIssueIdentifier("ABC-0123"), undefined) +}) + +// normalizeIssueIdentifier + +Deno.test("normalizeIssueIdentifier - uppercases team key", () => { + assertEquals(normalizeIssueIdentifier("abc-123"), "ABC-123") +}) + +Deno.test("normalizeIssueIdentifier - returns undefined for invalid input", () => { + assertEquals(normalizeIssueIdentifier("not-valid"), undefined) + assertEquals(normalizeIssueIdentifier("ABC-0"), undefined) +}) diff --git a/test/utils/jj.test.ts b/test/utils/jj.test.ts index c496ca14..4ccc0ea6 100644 --- a/test/utils/jj.test.ts +++ b/test/utils/jj.test.ts @@ -32,6 +32,13 @@ Deno.test("parseLinearIssueFromTrailer - handles multi-character team prefix", ( assertEquals(result, "TEAM-1") }) +Deno.test("parseLinearIssueFromTrailer - handles alphanumeric team keys", () => { + const trailer = + "[PLA4-16916](https://linear.app/workspace/issue/PLA4-16916/some-title)" + const result = parseLinearIssueFromTrailer(trailer) + assertEquals(result, "PLA4-16916") +}) + Deno.test("parseLinearIssueFromTrailer - handles large issue numbers", () => { const trailer = "[CLI-12345](https://linear.app/workspace/issue/CLI-12345/some-title)"