From 92d1af13c264c50c5aec8c901eecc4c8996f9476 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 19 Feb 2026 10:54:07 +0100 Subject: [PATCH 1/2] Align issue ID matching with Linear matching logic --- src/extractors.test.ts | 242 ++++++++++++++++++++++++++++++++++++++++- src/extractors.ts | 101 +++++++++++++++-- 2 files changed, 330 insertions(+), 13 deletions(-) diff --git a/src/extractors.test.ts b/src/extractors.test.ts index 1d8ab92..a8d0d7d 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -15,11 +15,11 @@ describe("extractLinearIssueIdentifiersForCommit", () => { expect(result).toEqual(["ENG-123"]); }); - it("extracts identifiers from commit message", () => { + it("extracts identifiers from commit message with magic words", () => { const commit: CommitContext = { sha: "abc123", branchName: "feature/no-key-here", - message: "Implements PLAT-42 and ENG-7 in one go", + message: "Fixes PLAT-42 and ENG-7 in one go", }; const result = extractLinearIssueIdentifiersForCommit(commit); @@ -31,7 +31,7 @@ describe("extractLinearIssueIdentifiersForCommit", () => { const commit: CommitContext = { sha: "abc123", branchName: "feature/eng-123-awesome-change", - message: "ENG-123 fixed, see ENG-123", + message: "Fixed ENG-123, see ENG-123", }; const result = extractLinearIssueIdentifiersForCommit(commit); @@ -63,6 +63,18 @@ describe("extractLinearIssueIdentifiersForCommit", () => { expect(result.sort()).toEqual(["A-1", "ABCDEFG-999", "X1Y2Z3A-100"].sort()); }); + it("does not extract identifiers from commit message without magic words", () => { + const commit: CommitContext = { + sha: "abc123", + branchName: "feature/no-key-here", + message: "See LIN-123 for details", + }; + + const result = extractLinearIssueIdentifiersForCommit(commit); + + expect(result).toEqual([]); + }); + it("does not match team keys longer than 7 characters", () => { const commit: CommitContext = { sha: "abc123", @@ -140,8 +152,8 @@ describe("underscore handling ", () => { describe("multiple identifiers ", () => { it.each([ - ["LIN-123 LIN-321", ["LIN-123", "LIN-321"]], - ["Closes issues LIN-123 and LIN-321", ["LIN-123", "LIN-321"]], + ["Fixes LIN-123 and LIN-321", ["LIN-123", "LIN-321"]], + ["Closes LIN-123, LIN-321", ["LIN-123", "LIN-321"]], ])("message %s should yield %j", (message, expected) => { const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", @@ -152,6 +164,226 @@ describe("multiple identifiers ", () => { }); }); +describe("commit message magic word behavior", () => { + it("extracts with closing keyword", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("extracts with contributing phrase", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Related to LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("does not extract without magic words", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "See LIN-123 for details", + }); + expect(result).toEqual([]); + }); + + it("extracts multiple keys after keyword", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes LIN-123, LIN-456 and ENG-789", + }); + expect(result.sort()).toEqual(["ENG-789", "LIN-123", "LIN-456"]); + }); + + it("extracts magic word in title line", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fix LIN-123: something", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("does not extract key in title without keyword", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "LIN-123: Fix something", + }); + expect(result).toEqual([]); + }); + + it.each([ + "close", + "closes", + "closed", + "closing", + "fix", + "fixes", + "fixed", + "fixing", + "resolve", + "resolves", + "resolved", + "resolving", + "complete", + "completes", + "completed", + "completing", + ])("closing keyword '%s' extracts issue", (keyword) => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: `${keyword} LIN-100`, + }); + expect(result).toEqual(["LIN-100"]); + }); + + it.each(["ref", "refs", "references", "part of", "related to", "relates to", "contributes to", "towards", "toward"])( + "contributing phrase '%s' extracts issue", + (phrase) => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: `${phrase} LIN-200`, + }); + expect(result).toEqual(["LIN-200"]); + }, + ); + + it("supports keyword with colon separator", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Closes: LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("only extracts keys preceded by magic word on same line", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "See LIN-111, fixes LIN-222", + }); + expect(result).toEqual(["LIN-222"]); + }); + + it("does not extract from the original bug scenario", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Title\nSeparate issue to follow up on that here LIN-60064", + }); + expect(result).toEqual([]); + }); + + it("branch provides keys independently of message magic words", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: "feature/LIN-100-something", + message: "See LIN-200 for details", + }); + expect(result).toEqual(["LIN-100"]); + }); + + it("is case insensitive for magic words", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "fIXES LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("extracts with multi-word phrase 'Part of'", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Part of LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("extracts with multi-word phrase 'Related to'", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Related to LIN-456", + }); + expect(result).toEqual(["LIN-456"]); + }); + + it("extracts issue from Linear URL with slug after magic word", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes https://linear.app/myorg/issue/LIN-123/fix-auth", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("extracts issue from Linear URL without slug after magic word", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes https://linear.app/myorg/issue/LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("does not extract Linear URL without magic word", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "See https://linear.app/myorg/issue/LIN-123/fix", + }); + expect(result).toEqual([]); + }); + + it("extracts mixed Linear URLs and raw IDs after magic word", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes https://linear.app/myorg/issue/LIN-123/slug, ENG-456 and LIN-789", + }); + expect(result.sort()).toEqual(["ENG-456", "LIN-123", "LIN-789"]); + }); + + it("extracts issue from http Linear URL after magic word", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes http://linear.app/myorg/issue/LIN-123", + }); + expect(result).toEqual(["LIN-123"]); + }); + + it("extracts issue from Linear URL with trailing slash", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Fixes https://linear.app/my-org/issue/LIN-213/", + }); + expect(result).toEqual(["LIN-213"]); + }); + + it("extracts issue from Linear URL with contributing phrase", () => { + const result = extractLinearIssueIdentifiersForCommit({ + sha: "abc", + branchName: null, + message: "Part of https://linear.app/myorg/issue/LIN-213/some-slug", + }); + expect(result).toEqual(["LIN-213"]); + }); +}); + describe("extractPullRequestNumbersForCommit", () => { // Messages that should extract PR numbers it.each([ diff --git a/src/extractors.ts b/src/extractors.ts index ffb26fe..66cc8b9 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -19,6 +19,61 @@ const ISSUE_IDENTIFIER_REGEX = new RegExp( "gi", ); +const LINEAR_ISSUE_URL_REGEX = /https?:\/\/linear\.app\/[\w-]+\/issue\/(\w{1,7}-[0-9]{1,9})(?:\/[\w-]*)*/gi; + +function normalizeLinearUrls(text: string): string { + return text.replace(LINEAR_ISSUE_URL_REGEX, "$1"); +} + +/** Magic words that indicate a commit is closing/fixing an issue. Matches Linear's detection. */ +const CLOSING_WORDS = [ + "close", + "closes", + "closed", + "closing", + "fix", + "fixes", + "fixed", + "fixing", + "resolve", + "resolves", + "resolved", + "resolving", + "complete", + "completes", + "completed", + "completing", +]; + +/** Magic phrases that indicate a commit contributes to an issue. Matches Linear's detection. */ +const CONTRIBUTING_PHRASES = [ + "ref", + "refs", + "references", + "part of", + "related to", + "relates to", + "contributes to", + "towards", + "toward", +]; + +/** + * Core issue ID pattern without word boundaries — used inside the magic word + * composite regex where surrounding context already provides boundaries. + */ +const ISSUE_ID_CORE = `\\w{1,${MAX_KEY_LENGTH}}-[0-9]{1,9}(?!\\.\\d)`; + +/** + * Build a regex that matches magic words followed by one or more issue identifiers. + * Pattern per line, matching Linear's detection: + * \b(magic_words)[\s:]+(ISSUE_ID(([,\s]|\band\b|&)+ISSUE_ID)*) + */ +const MAGIC_WORD_REGEX = new RegExp( + `\\b(${[...CLOSING_WORDS, ...CONTRIBUTING_PHRASES].join("|")})[\\s:]+(${ISSUE_ID_CORE}(?:(?:[\\s,]|\\band\\b|&)+${ISSUE_ID_CORE})*)`, + "gi", +); + type IdentifierMatch = { identifier: string; rawIdentifier: string; @@ -49,22 +104,52 @@ function matchAllIdentifiers(text: string): IdentifierMatch[] { return results; } -export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): string[] { - if (!commit) { - return []; +/** + * Extract issue identifiers from text only when preceded by a magic word. + * Processes text line-by-line, matching Linear's detection behavior. + */ +function matchMagicWordIdentifiers(text: string): IdentifierMatch[] { + const results: IdentifierMatch[] = []; + const lines = text.split(/\r?\n/); + + for (let line of lines) { + line = normalizeLinearUrls(line); + const regex = new RegExp(MAGIC_WORD_REGEX.source, "gi"); + let match; + while ((match = regex.exec(line)) !== null) { + // match[2] contains the captured issue keys portion (one or more IDs) + const issueKeysPortion = match[2]; + if (issueKeysPortion) { + const identifiers = matchAllIdentifiers(issueKeysPortion); + results.push(...identifiers); + } + } } - const sources = [commit.branchName ?? "", commit.message ?? ""].filter((value) => value.length > 0); + return results; +} - if (sources.length === 0) { +export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): string[] { + if (!commit) { return []; } const found = new Map(); - for (const source of sources) { - const matches = matchAllIdentifiers(source); - for (const match of matches) { + // Branch name: extract all matches (branch names are always intentional) + const branchName = commit.branchName ?? ""; + if (branchName.length > 0) { + for (const match of matchAllIdentifiers(branchName)) { + if (!found.has(match.identifier)) { + found.set(match.identifier, match.rawIdentifier); + } + } + } + + // Commit message: only extract when preceded by a magic word + const message = commit.message ?? ""; + if (message.length > 0) { + for (const match of matchMagicWordIdentifiers(message)) { if (!found.has(match.identifier)) { found.set(match.identifier, match.rawIdentifier); } From cca20d9816f2f9f0c779287180429c465dabd8f7 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 19 Feb 2026 11:02:05 +0100 Subject: [PATCH 2/2] Bump package.json to 0.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 22d3365..f59750e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linear-release", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "CLI tool to integrate CI/CD pipelines with Linear releases", "homepage": "https://github.com/linear/linear-release",