From b5fce52fd85c9e1e4548aa9ca70377214f2ce625 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 11:45:06 +0200 Subject: [PATCH 1/3] Add --issue-pattern flag for custom subject identifier extraction --- README.md | 18 +++++++ src/args.test.ts | 20 ++++++++ src/args.ts | 20 ++++++++ src/extractors.test.ts | 106 +++++++++++++++++++++++++++++++++++++++++ src/extractors.ts | 29 ++++++++++- src/index.ts | 3 ++ src/scan.test.ts | 47 +++++++++++++++++- src/scan.ts | 27 +++++++++-- src/types.ts | 3 +- 9 files changed, 266 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 235ddc0..a95ea64 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ linear-release update --stage="in review" --name="Release 1.2.0" | `--stage` | `update` | Target deployment stage (required for `update`) | | `--include-paths` | `sync` | Filter commits by changed file paths | | `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | +| `--issue-pattern` | `sync` | Extract issue identifiers captured by group 1 from commit subjects | | `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | | `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | | `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | @@ -233,6 +234,23 @@ The regex is matched against the commit subject only (everything before the firs `--include-subjects` composes with `--include-paths`: a commit must pass both filters to be scanned. +### Custom issue patterns + +Use `--issue-pattern` to extract issue identifiers from a custom commit-subject convention. Capture the identifier in group 1; the regex is applied to the subject only, case-insensitively, and anywhere in the subject, with all matches collected. It is additive to the built-in branch-name, magic-word, and subject-pattern detection. + +```bash +# Conventional Commits with a bracketed identifier +linear-release sync --issue-pattern='\[([A-Z]+-\d+)\]' +# Matches: feat(routing)[ENG-123]: add stop reordering + +# Bare identifiers anywhere in the subject +linear-release sync --issue-pattern='\b([A-Z]+-\d+)\b' +# Matches: feat(api): ASP-621 handle payload +# Also matches both IDs: fix(scope): color button red DEV-123 DEV-124 +``` + +Identifiers that do not exist in the workspace are ignored server-side, so the broad recipe is safe but can be noisy (for example, it also matches `UTF-8`). Reverting a commit whose issues were linked only through `--issue-pattern` does not un-link them, just as with the built-in subject patterns. + ### Release Links `--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard. diff --git a/src/args.test.ts b/src/args.test.ts index c680ab2..e122147 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -120,6 +120,26 @@ describe("parseCLIArgs", () => { expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/); }); + it("defaults --issue-pattern to null", () => { + expect(parseCLIArgs([]).issuePattern).toBeNull(); + }); + + it("returns --issue-pattern as the raw pattern string", () => { + expect(parseCLIArgs(["--issue-pattern", "\\[([A-Z]+-\\d+)\\]"]).issuePattern).toBe("\\[([A-Z]+-\\d+)\\]"); + }); + + it("treats empty --issue-pattern as disabled", () => { + expect(parseCLIArgs(["--issue-pattern", ""]).issuePattern).toBeNull(); + }); + + it("throws a helpful error on invalid --issue-pattern regex", () => { + expect(() => parseCLIArgs(["--issue-pattern", "([unclosed"])).toThrow(/Invalid --issue-pattern regex/); + }); + + it("requires capture group 1 for --issue-pattern", () => { + expect(() => parseCLIArgs(["--issue-pattern", "\\[[A-Z]+-\\d+\\]"])).toThrow(/group 1.*capture/i); + }); + it("parses repeatable --link values", () => { const result = parseCLIArgs([ "sync", diff --git a/src/args.ts b/src/args.ts index 73c7552..04fce4f 100644 --- a/src/args.ts +++ b/src/args.ts @@ -27,6 +27,7 @@ export type ParsedCLIArgs = { baseRef?: string; includePaths: string[]; includeSubjects: string | null; + issuePattern: string | null; links: ReleaseLink[]; documents: ReleaseDocumentSpec[]; releaseNotes?: ReleaseNoteSpec; @@ -133,6 +134,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "base-ref": { type: "string" }, "include-paths": { type: "string" }, "include-subjects": { type: "string" }, + "issue-pattern": { type: "string" }, link: { type: "string", multiple: true }, document: { type: "string", multiple: true }, "document-file": { type: "string", multiple: true }, @@ -178,6 +180,23 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { } includeSubjects = rawIncludeSubjects; } + + let issuePattern: string | null = null; + const rawIssuePattern = values["issue-pattern"]; + if (rawIssuePattern !== undefined && rawIssuePattern.length > 0) { + try { + new RegExp(rawIssuePattern); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid --issue-pattern regex: ${detail}`); + } + if (new RegExp(rawIssuePattern + "|").exec("")!.length - 1 === 0) { + throw new Error( + "Invalid --issue-pattern regex: capture group 1 must capture the identifier (e.g. \\[([A-Z]+-\\d+)\\])", + ); + } + issuePattern = rawIssuePattern; + } const command = positionals[0] || "sync"; const links = (values.link ?? []).map(parseReleaseLink); @@ -226,6 +245,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .filter((p) => p.length > 0) : [], includeSubjects, + issuePattern, links, documents, releaseNotes, diff --git a/src/extractors.test.ts b/src/extractors.test.ts index 98e2885..eba0d81 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -472,6 +472,112 @@ describe("bracketed identifier in commit subject", () => { }); }); +describe("custom issue patterns", () => { + const bracketedPattern = /\[([A-Z]+-\d+)\]/gi; + + it.each([ + ["feat(routing)[ENG-123]: add stop reordering", ["ENG-123"]], + ["fix[ENG-123]: handle empty payload", ["ENG-123"]], + ["chore(deps)[ENG-7]: bump", ["ENG-7"]], + ["feat(vehicle)[LMC-123]: some commit reason", ["LMC-123"]], + ["feat(api)![ENG-9]: breaking change", ["ENG-9"]], + ["chore[ENG-7][ENG-8]: bump deps", ["ENG-7", "ENG-8"]], + ])("extracts bracketed identifiers from %s", (message, expected) => { + const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message }, { issuePattern: bracketedPattern }); + expect(ids(result)).toEqual(expected); + expect(result.every((entry) => entry.source === "issue_pattern")).toBe(true); + }); + + it("deduplicates custom, magic-word, and built-in subject identifiers", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "[LIN-1], [LIN-2] and [LIN-3] thing, Closes LIN-4, [LIN-5]" }, + { issuePattern: bracketedPattern }, + ); + expect(ids(result).sort()).toEqual(["LIN-1", "LIN-2", "LIN-3", "LIN-4", "LIN-5"]); + }); + + it("normalizes lowercase identifiers with a case-insensitive pattern", () => { + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat(api)[eng-123]: x" }, + { issuePattern: /\[([A-Z]+-\d+)\]/gi }, + ), + ), + ).toEqual(["ENG-123"]); + }); + + it("rejects leading-zero and malformed captured identifiers", () => { + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat[ENG-0045]: x" }, + { issuePattern: bracketedPattern }, + ), + ), + ).toEqual([]); + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "chore(deps)[notanid]: bump" }, + { issuePattern: bracketedPattern }, + ), + ), + ).toEqual([]); + }); + + it("only scans the subject", () => { + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "chore: bump\n\n[ENG-123] in body" }, + { issuePattern: bracketedPattern }, + ), + ), + ).toEqual([]); + }); + + it("treats absent and null options identically", () => { + const commit = { sha: "abc", message: "chore: bump" }; + expect(extractLinearIssueIdentifiersForCommit(commit)).toEqual( + extractLinearIssueIdentifiersForCommit(commit, { issuePattern: null }), + ); + }); + + it("keeps branch-name source when the custom pattern finds the same identifier", () => { + const result = extractLinearIssueIdentifiersForCommit( + { sha: "abc", branchName: "feature/ENG-99-x", message: "feat[ENG-99]: y" }, + { issuePattern: bracketedPattern }, + ); + expect(result).toEqual([{ identifier: "ENG-99", source: "branch_name" }]); + }); + + it("extracts bare identifiers globally", () => { + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "feat(api): ASP-621 handle empty payload" }, + { issuePattern: /\b([A-Z]+-\d+)\b/gi }, + ), + ), + ).toEqual(["ASP-621"]); + expect( + ids( + extractLinearIssueIdentifiersForCommit( + { sha: "abc", message: "fix(scope): color button red DEV-123 DEV-124" }, + { issuePattern: /\b([A-Z]+-\d+)\b/gi }, + ), + ), + ).toEqual(["DEV-123", "DEV-124"]); + }); + + it("handles zero-width-capable patterns without hanging", () => { + expect( + ids(extractLinearIssueIdentifiersForCommit({ sha: "abc", message: "chore: bump" }, { issuePattern: /(\d*)/gi })), + ).toEqual([]); + }); +}); + describe("revert branch handling", () => { it("blocks extraction from merge commit with revert branch name", () => { const result = extractLinearIssueIdentifiersForCommit({ diff --git a/src/extractors.ts b/src/extractors.ts index 1061a62..6dc54e6 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -200,6 +200,18 @@ function matchCommonSubjectPatterns(message: string): IdentifierMatch[] { return results; } +function matchCustomSubjectPattern(message: string, pattern: RegExp): IdentifierMatch[] { + const subject = getCommitSubject(message); + const globalPattern = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`); + const results: IdentifierMatch[] = []; + for (const match of subject.matchAll(globalPattern)) { + if (match[1]) { + results.push(...matchAllIdentifiers(match[1])); + } + } + return results; +} + /** * Extract issue identifiers from text only when preceded by a magic word. * Processes text line-by-line, matching Linear's detection behavior. @@ -227,10 +239,13 @@ function matchMagicWordIdentifiers(text: string): IdentifierMatch[] { export type ExtractedIdentifier = { identifier: string; - source: "branch_name" | "commit_message"; + source: "branch_name" | "commit_message" | "issue_pattern"; }; -export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] { +export function extractLinearIssueIdentifiersForCommit( + commit: CommitContext, + options: { issuePattern?: RegExp | null } = {}, +): ExtractedIdentifier[] { if (!commit) { return []; } @@ -275,6 +290,16 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E }); } } + if (options.issuePattern) { + for (const match of matchCustomSubjectPattern(message, options.issuePattern)) { + if (!found.has(match.identifier)) { + found.set(match.identifier, { + identifier: match.identifier, + source: "issue_pattern", + }); + } + } + } } return Array.from(found.values()); diff --git a/src/index.ts b/src/index.ts index ae6a256..cc6c203 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,7 @@ Options: --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --include-subjects= Filter commits whose subject (first line) matches the regex + --issue-pattern= Extract issue IDs captured by group 1 from commit subjects (e.g. "\\[([A-Z]+-\\d+)\\]") --link Add a link to the targeted release (repeatable) --document Attach a document to the release (repeatable, Title required) --document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable) @@ -122,6 +123,7 @@ const { baseRef, includePaths, includeSubjects, + issuePattern, links, documents: documentSpecs, releaseNotes: releaseNotesSpec, @@ -360,6 +362,7 @@ async function syncCommand(): Promise<{ const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, { includePaths: effectiveIncludePaths, includeSubjects, + issuePattern, }); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 4e891de..9092af3 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as log from "./log"; import { scanCommits } from "./scan"; import { CommitContext } from "./types"; @@ -228,4 +229,48 @@ describe("scanCommits", () => { expect(ids(result.revertedIssueReferences)).toEqual([]); }); }); + + describe("--issue-pattern", () => { + it("compiles the string pattern globally and case-insensitively", () => { + const result = scanCommits( + [ + { sha: "c1", message: "feat(api)[eng-123]: x" }, + { sha: "c2", message: "fix: DEV-123 DEV-124" }, + ], + { issuePattern: "\\b([A-Z]+-\\d+)\\b" }, + ); + expect(ids(result.issueReferences)).toEqual(["DEV-123", "DEV-124", "ENG-123"]); + }); + + it("records the raw pattern on the debug sink", () => { + expect(scanCommits([], { issuePattern: "\\[([A-Z]+-\\d+)\\]" }).debugSink.issuePattern).toBe( + "\\[([A-Z]+-\\d+)\\]", + ); + expect(scanCommits([], {}).debugSink.issuePattern).toBeNull(); + }); + + it("does not scan issue patterns on commits excluded by --include-subjects", () => { + const result = scanCommits([{ sha: "c1", message: "chore[ENG-123]: bump" }], { + includeSubjects: "^feat:", + issuePattern: "\\[([A-Z]+-\\d+)\\]", + }); + expect(result.issueReferences).toEqual([]); + expect(result.debugSink.inspectedShas).toEqual([]); + }); + + it("warns when the pattern matches but group 1 has no valid identifier", () => { + const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + scanCommits([{ sha: "c1", message: "chore[notanid]: bump" }], { issuePattern: "\\[(\\w+)\\]" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("group 1")); + warn.mockRestore(); + }); + + it("does not warn when the flag is unset", () => { + const warn = vi.spyOn(log, "warn").mockImplementation(() => {}); + scanCommits([{ sha: "c1", message: "chore[notanid]: bump" }]); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); }); diff --git a/src/scan.ts b/src/scan.ts index 0398428..b654df1 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -4,12 +4,13 @@ import { extractRevertedIssueIdentifiersForCommit, getEffectiveSubject, } from "./extractors"; -import { verbose } from "./log"; +import { verbose, warn } from "./log"; import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./types"; export type ScanOptions = { includePaths?: string[] | null; includeSubjects?: string | null; + issuePattern?: string | null; }; /** @@ -26,8 +27,12 @@ export function scanCommits( prNumbers: number[]; debugSink: DebugSink; } { - const { includePaths = null, includeSubjects = null } = options; + const { includePaths = null, includeSubjects = null, issuePattern = null } = options; const subjectRegex = includeSubjects ? new RegExp(includeSubjects) : null; + const issuePatternRegex = issuePattern ? new RegExp(issuePattern, "gi") : null; + const issuePatternSubjectRegex = issuePattern ? new RegExp(issuePattern, "i") : null; + let issuePatternMatchedSubject = false; + let issuePatternExtractedIdentifier = false; const lastAction = new Map(); const addedRefs = new Map(); const revertedRefs = new Map(); @@ -40,6 +45,7 @@ export function scanCommits( pullRequests: [], includePaths, includeSubjects, + issuePattern, }; for (const commit of commits) { @@ -53,6 +59,10 @@ export function scanCommits( debugSink.inspectedShas.push(commit.sha); + if (issuePatternSubjectRegex?.test(getEffectiveSubject(commit.message))) { + issuePatternMatchedSubject = true; + } + for (const { identifier, source } of extractRevertedIssueIdentifiersForCommit(commit)) { if (!debugSink.revertedIssues[identifier]) { debugSink.revertedIssues[identifier] = []; @@ -68,7 +78,12 @@ export function scanCommits( verbose(`Detected reverted issue key ${identifier} from commit ${commit.sha}`); } - for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit)) { + for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit, { + issuePattern: issuePatternRegex, + })) { + if (source === "issue_pattern") { + issuePatternExtractedIdentifier = true; + } if (!debugSink.issues[identifier]) { debugSink.issues[identifier] = []; } @@ -99,6 +114,12 @@ export function scanCommits( } } + if (issuePattern && issuePatternMatchedSubject && !issuePatternExtractedIdentifier) { + warn( + "--issue-pattern matched commit subjects but group 1 never contained a valid identifier like ENG-123; likely the capture groups are misplaced.", + ); + } + const issueReferences: IssueReference[] = []; const revertedIssueReferences: IssueReference[] = []; for (const [identifier, action] of lastAction) { diff --git a/src/types.ts b/src/types.ts index 6f5d5f5..1e5d82c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -92,7 +92,7 @@ export type IssueReference = { // Debug sink types export type IssueSource = { sha: string; - source: "branch_name" | "commit_message"; + source: "branch_name" | "commit_message" | "issue_pattern"; value: string; // The actual branch name or commit message }; @@ -109,4 +109,5 @@ export type DebugSink = { pullRequests: PullRequestSource[]; // PR numbers found in commits includePaths: string[] | null; // Path filters applied during commit scanning includeSubjects: string | null; // Subject regex source applied during scanning + issuePattern: string | null; // Subject identifier extraction regex source }; From dacb188d6bfc59f5a070efa77291d0f641db3045 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 11:49:29 +0200 Subject: [PATCH 2/3] Use a generic identifier in the bare-pattern examples --- README.md | 2 +- src/extractors.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a95ea64..3e4263e 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ linear-release sync --issue-pattern='\[([A-Z]+-\d+)\]' # Bare identifiers anywhere in the subject linear-release sync --issue-pattern='\b([A-Z]+-\d+)\b' -# Matches: feat(api): ASP-621 handle payload +# Matches: feat(api): ENG-621 handle payload # Also matches both IDs: fix(scope): color button red DEV-123 DEV-124 ``` diff --git a/src/extractors.test.ts b/src/extractors.test.ts index eba0d81..ac7c5b6 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -556,11 +556,11 @@ describe("custom issue patterns", () => { expect( ids( extractLinearIssueIdentifiersForCommit( - { sha: "abc", message: "feat(api): ASP-621 handle empty payload" }, + { sha: "abc", message: "feat(api): ENG-621 handle empty payload" }, { issuePattern: /\b([A-Z]+-\d+)\b/gi }, ), ), - ).toEqual(["ASP-621"]); + ).toEqual(["ENG-621"]); expect( ids( extractLinearIssueIdentifiersForCommit( From eb9b9637eab6277224767e7c63b2b620f5ad9847 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 11:56:19 +0200 Subject: [PATCH 3/3] Use generic team keys in custom pattern tests --- src/extractors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extractors.test.ts b/src/extractors.test.ts index ac7c5b6..bf523a1 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -479,7 +479,7 @@ describe("custom issue patterns", () => { ["feat(routing)[ENG-123]: add stop reordering", ["ENG-123"]], ["fix[ENG-123]: handle empty payload", ["ENG-123"]], ["chore(deps)[ENG-7]: bump", ["ENG-7"]], - ["feat(vehicle)[LMC-123]: some commit reason", ["LMC-123"]], + ["feat(mobile)[APP-123]: some commit reason", ["APP-123"]], ["feat(api)![ENG-9]: breaking change", ["ENG-9"]], ["chore[ENG-7][ENG-8]: bump deps", ["ENG-7", "ENG-8"]], ])("extracts bracketed identifiers from %s", (message, expected) => {