Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/commands/issue/issue-update.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions src/utils/issue-identifier.ts
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 3 additions & 13 deletions src/utils/jj.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { findIssueIdentifierInText } from "./issue-identifier.ts"

/**
* Utilities for jj (Jujutsu) version control system
*/
Expand Down Expand Up @@ -92,19 +94,7 @@ export async function createJjNewChange(): Promise<void> {
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
}

/**
Expand Down
29 changes: 13 additions & 16 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand All @@ -69,22 +66,22 @@ export function getTeamKey(): string | undefined {
export async function getIssueIdentifier(
providedId?: string,
): Promise<string | undefined> {
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) {
Expand Down
15 changes: 6 additions & 9 deletions src/utils/vcs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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,
getJjLinearIssue,
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"

Expand Down Expand Up @@ -66,12 +67,8 @@ export async function getCurrentIssueFromVcs(): Promise<string | null> {
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()
Expand Down
11 changes: 11 additions & 0 deletions test/commands/issue/__snapshots__/issue-update.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions test/commands/issue/issue-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions test/utils/issue-identifier.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading
Loading