Skip to content

Commit a0a2903

Browse files
authored
Add --issue-pattern flag for custom subject identifier extraction (#124)
1 parent fd89c16 commit a0a2903

9 files changed

Lines changed: 266 additions & 7 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ The provider is detected from the remote hostname, or on GitLab CI from the job
159159
| `--stage` | `update` | Target deployment stage (required for `update`) |
160160
| `--include-paths` | `sync` | Filter commits by changed file paths |
161161
| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex |
162+
| `--issue-pattern` | `sync` | Extract issue identifiers captured by group 1 from commit subjects |
162163
| `--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. |
163164
| `--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. |
164165
| `--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. |
@@ -236,6 +237,23 @@ The regex is matched against the commit subject only (everything before the firs
236237

237238
`--include-subjects` composes with `--include-paths`: a commit must pass both filters to be scanned.
238239

240+
### Custom issue patterns
241+
242+
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.
243+
244+
```bash
245+
# Conventional Commits with a bracketed identifier
246+
linear-release sync --issue-pattern='\[([A-Z]+-\d+)\]'
247+
# Matches: feat(routing)[ENG-123]: add stop reordering
248+
249+
# Bare identifiers anywhere in the subject
250+
linear-release sync --issue-pattern='\b([A-Z]+-\d+)\b'
251+
# Matches: feat(api): ENG-621 handle payload
252+
# Also matches both IDs: fix(scope): color button red DEV-123 DEV-124
253+
```
254+
255+
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.
256+
239257
### Release Links
240258

241259
`--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard.

src/args.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,26 @@ describe("parseCLIArgs", () => {
120120
expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/);
121121
});
122122

123+
it("defaults --issue-pattern to null", () => {
124+
expect(parseCLIArgs([]).issuePattern).toBeNull();
125+
});
126+
127+
it("returns --issue-pattern as the raw pattern string", () => {
128+
expect(parseCLIArgs(["--issue-pattern", "\\[([A-Z]+-\\d+)\\]"]).issuePattern).toBe("\\[([A-Z]+-\\d+)\\]");
129+
});
130+
131+
it("treats empty --issue-pattern as disabled", () => {
132+
expect(parseCLIArgs(["--issue-pattern", ""]).issuePattern).toBeNull();
133+
});
134+
135+
it("throws a helpful error on invalid --issue-pattern regex", () => {
136+
expect(() => parseCLIArgs(["--issue-pattern", "([unclosed"])).toThrow(/Invalid --issue-pattern regex/);
137+
});
138+
139+
it("requires capture group 1 for --issue-pattern", () => {
140+
expect(() => parseCLIArgs(["--issue-pattern", "\\[[A-Z]+-\\d+\\]"])).toThrow(/group 1.*capture/i);
141+
});
142+
123143
it("parses repeatable --link values", () => {
124144
const result = parseCLIArgs([
125145
"sync",

src/args.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type ParsedCLIArgs = {
2727
baseRef?: string;
2828
includePaths: string[];
2929
includeSubjects: string | null;
30+
issuePattern: string | null;
3031
links: ReleaseLink[];
3132
documents: ReleaseDocumentSpec[];
3233
releaseNotes?: ReleaseNoteSpec;
@@ -133,6 +134,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
133134
"base-ref": { type: "string" },
134135
"include-paths": { type: "string" },
135136
"include-subjects": { type: "string" },
137+
"issue-pattern": { type: "string" },
136138
link: { type: "string", multiple: true },
137139
document: { type: "string", multiple: true },
138140
"document-file": { type: "string", multiple: true },
@@ -178,6 +180,23 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
178180
}
179181
includeSubjects = rawIncludeSubjects;
180182
}
183+
184+
let issuePattern: string | null = null;
185+
const rawIssuePattern = values["issue-pattern"];
186+
if (rawIssuePattern !== undefined && rawIssuePattern.length > 0) {
187+
try {
188+
new RegExp(rawIssuePattern);
189+
} catch (err) {
190+
const detail = err instanceof Error ? err.message : String(err);
191+
throw new Error(`Invalid --issue-pattern regex: ${detail}`);
192+
}
193+
if (new RegExp(rawIssuePattern + "|").exec("")!.length - 1 === 0) {
194+
throw new Error(
195+
"Invalid --issue-pattern regex: capture group 1 must capture the identifier (e.g. \\[([A-Z]+-\\d+)\\])",
196+
);
197+
}
198+
issuePattern = rawIssuePattern;
199+
}
181200
const command = positionals[0] || "sync";
182201
const links = (values.link ?? []).map(parseReleaseLink);
183202

@@ -226,6 +245,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
226245
.filter((p) => p.length > 0)
227246
: [],
228247
includeSubjects,
248+
issuePattern,
229249
links,
230250
documents,
231251
releaseNotes,

src/extractors.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,112 @@ describe("bracketed identifier in commit subject", () => {
472472
});
473473
});
474474

475+
describe("custom issue patterns", () => {
476+
const bracketedPattern = /\[([A-Z]+-\d+)\]/gi;
477+
478+
it.each([
479+
["feat(routing)[ENG-123]: add stop reordering", ["ENG-123"]],
480+
["fix[ENG-123]: handle empty payload", ["ENG-123"]],
481+
["chore(deps)[ENG-7]: bump", ["ENG-7"]],
482+
["feat(mobile)[APP-123]: some commit reason", ["APP-123"]],
483+
["feat(api)![ENG-9]: breaking change", ["ENG-9"]],
484+
["chore[ENG-7][ENG-8]: bump deps", ["ENG-7", "ENG-8"]],
485+
])("extracts bracketed identifiers from %s", (message, expected) => {
486+
const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message }, { issuePattern: bracketedPattern });
487+
expect(ids(result)).toEqual(expected);
488+
expect(result.every((entry) => entry.source === "issue_pattern")).toBe(true);
489+
});
490+
491+
it("deduplicates custom, magic-word, and built-in subject identifiers", () => {
492+
const result = extractLinearIssueIdentifiersForCommit(
493+
{ sha: "abc", message: "[LIN-1], [LIN-2] and [LIN-3] thing, Closes LIN-4, [LIN-5]" },
494+
{ issuePattern: bracketedPattern },
495+
);
496+
expect(ids(result).sort()).toEqual(["LIN-1", "LIN-2", "LIN-3", "LIN-4", "LIN-5"]);
497+
});
498+
499+
it("normalizes lowercase identifiers with a case-insensitive pattern", () => {
500+
expect(
501+
ids(
502+
extractLinearIssueIdentifiersForCommit(
503+
{ sha: "abc", message: "feat(api)[eng-123]: x" },
504+
{ issuePattern: /\[([A-Z]+-\d+)\]/gi },
505+
),
506+
),
507+
).toEqual(["ENG-123"]);
508+
});
509+
510+
it("rejects leading-zero and malformed captured identifiers", () => {
511+
expect(
512+
ids(
513+
extractLinearIssueIdentifiersForCommit(
514+
{ sha: "abc", message: "feat[ENG-0045]: x" },
515+
{ issuePattern: bracketedPattern },
516+
),
517+
),
518+
).toEqual([]);
519+
expect(
520+
ids(
521+
extractLinearIssueIdentifiersForCommit(
522+
{ sha: "abc", message: "chore(deps)[notanid]: bump" },
523+
{ issuePattern: bracketedPattern },
524+
),
525+
),
526+
).toEqual([]);
527+
});
528+
529+
it("only scans the subject", () => {
530+
expect(
531+
ids(
532+
extractLinearIssueIdentifiersForCommit(
533+
{ sha: "abc", message: "chore: bump\n\n[ENG-123] in body" },
534+
{ issuePattern: bracketedPattern },
535+
),
536+
),
537+
).toEqual([]);
538+
});
539+
540+
it("treats absent and null options identically", () => {
541+
const commit = { sha: "abc", message: "chore: bump" };
542+
expect(extractLinearIssueIdentifiersForCommit(commit)).toEqual(
543+
extractLinearIssueIdentifiersForCommit(commit, { issuePattern: null }),
544+
);
545+
});
546+
547+
it("keeps branch-name source when the custom pattern finds the same identifier", () => {
548+
const result = extractLinearIssueIdentifiersForCommit(
549+
{ sha: "abc", branchName: "feature/ENG-99-x", message: "feat[ENG-99]: y" },
550+
{ issuePattern: bracketedPattern },
551+
);
552+
expect(result).toEqual([{ identifier: "ENG-99", source: "branch_name" }]);
553+
});
554+
555+
it("extracts bare identifiers globally", () => {
556+
expect(
557+
ids(
558+
extractLinearIssueIdentifiersForCommit(
559+
{ sha: "abc", message: "feat(api): ENG-621 handle empty payload" },
560+
{ issuePattern: /\b([A-Z]+-\d+)\b/gi },
561+
),
562+
),
563+
).toEqual(["ENG-621"]);
564+
expect(
565+
ids(
566+
extractLinearIssueIdentifiersForCommit(
567+
{ sha: "abc", message: "fix(scope): color button red DEV-123 DEV-124" },
568+
{ issuePattern: /\b([A-Z]+-\d+)\b/gi },
569+
),
570+
),
571+
).toEqual(["DEV-123", "DEV-124"]);
572+
});
573+
574+
it("handles zero-width-capable patterns without hanging", () => {
575+
expect(
576+
ids(extractLinearIssueIdentifiersForCommit({ sha: "abc", message: "chore: bump" }, { issuePattern: /(\d*)/gi })),
577+
).toEqual([]);
578+
});
579+
});
580+
475581
describe("revert branch handling", () => {
476582
it("blocks extraction from merge commit with revert branch name", () => {
477583
const result = extractLinearIssueIdentifiersForCommit({

src/extractors.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ function matchCommonSubjectPatterns(message: string): IdentifierMatch[] {
200200
return results;
201201
}
202202

203+
function matchCustomSubjectPattern(message: string, pattern: RegExp): IdentifierMatch[] {
204+
const subject = getCommitSubject(message);
205+
const globalPattern = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
206+
const results: IdentifierMatch[] = [];
207+
for (const match of subject.matchAll(globalPattern)) {
208+
if (match[1]) {
209+
results.push(...matchAllIdentifiers(match[1]));
210+
}
211+
}
212+
return results;
213+
}
214+
203215
/**
204216
* Extract issue identifiers from text only when preceded by a magic word.
205217
* Processes text line-by-line, matching Linear's detection behavior.
@@ -227,10 +239,13 @@ function matchMagicWordIdentifiers(text: string): IdentifierMatch[] {
227239

228240
export type ExtractedIdentifier = {
229241
identifier: string;
230-
source: "branch_name" | "commit_message";
242+
source: "branch_name" | "commit_message" | "issue_pattern";
231243
};
232244

233-
export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] {
245+
export function extractLinearIssueIdentifiersForCommit(
246+
commit: CommitContext,
247+
options: { issuePattern?: RegExp | null } = {},
248+
): ExtractedIdentifier[] {
234249
if (!commit) {
235250
return [];
236251
}
@@ -275,6 +290,16 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E
275290
});
276291
}
277292
}
293+
if (options.issuePattern) {
294+
for (const match of matchCustomSubjectPattern(message, options.issuePattern)) {
295+
if (!found.has(match.identifier)) {
296+
found.set(match.identifier, {
297+
identifier: match.identifier,
298+
source: "issue_pattern",
299+
});
300+
}
301+
}
302+
}
278303
}
279304

280305
return Array.from(found.values());

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Options:
6666
--stage=<stage> Deployment stage (required for update)
6767
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
6868
--include-subjects=<regex> Filter commits whose subject (first line) matches the regex
69+
--issue-pattern=<regex> Extract issue IDs captured by group 1 from commit subjects (e.g. "\\[([A-Z]+-\\d+)\\]")
6970
--link <URL|Label=URL> Add a link to the targeted release (repeatable)
7071
--document <Title=content> Attach a document to the release (repeatable, Title required)
7172
--document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable)
@@ -124,6 +125,7 @@ const {
124125
baseRef,
125126
includePaths,
126127
includeSubjects,
128+
issuePattern,
127129
links,
128130
documents: documentSpecs,
129131
releaseNotes: releaseNotesSpec,
@@ -364,6 +366,7 @@ async function syncCommand(): Promise<{
364366
const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, {
365367
includePaths: effectiveIncludePaths,
366368
includeSubjects,
369+
issuePattern,
367370
});
368371

369372
verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`);

src/scan.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
2+
import * as log from "./log";
23
import { scanCommits } from "./scan";
34
import { CommitContext } from "./types";
45

@@ -228,4 +229,48 @@ describe("scanCommits", () => {
228229
expect(ids(result.revertedIssueReferences)).toEqual([]);
229230
});
230231
});
232+
233+
describe("--issue-pattern", () => {
234+
it("compiles the string pattern globally and case-insensitively", () => {
235+
const result = scanCommits(
236+
[
237+
{ sha: "c1", message: "feat(api)[eng-123]: x" },
238+
{ sha: "c2", message: "fix: DEV-123 DEV-124" },
239+
],
240+
{ issuePattern: "\\b([A-Z]+-\\d+)\\b" },
241+
);
242+
expect(ids(result.issueReferences)).toEqual(["DEV-123", "DEV-124", "ENG-123"]);
243+
});
244+
245+
it("records the raw pattern on the debug sink", () => {
246+
expect(scanCommits([], { issuePattern: "\\[([A-Z]+-\\d+)\\]" }).debugSink.issuePattern).toBe(
247+
"\\[([A-Z]+-\\d+)\\]",
248+
);
249+
expect(scanCommits([], {}).debugSink.issuePattern).toBeNull();
250+
});
251+
252+
it("does not scan issue patterns on commits excluded by --include-subjects", () => {
253+
const result = scanCommits([{ sha: "c1", message: "chore[ENG-123]: bump" }], {
254+
includeSubjects: "^feat:",
255+
issuePattern: "\\[([A-Z]+-\\d+)\\]",
256+
});
257+
expect(result.issueReferences).toEqual([]);
258+
expect(result.debugSink.inspectedShas).toEqual([]);
259+
});
260+
261+
it("warns when the pattern matches but group 1 has no valid identifier", () => {
262+
const warn = vi.spyOn(log, "warn").mockImplementation(() => {});
263+
scanCommits([{ sha: "c1", message: "chore[notanid]: bump" }], { issuePattern: "\\[(\\w+)\\]" });
264+
expect(warn).toHaveBeenCalledTimes(1);
265+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("group 1"));
266+
warn.mockRestore();
267+
});
268+
269+
it("does not warn when the flag is unset", () => {
270+
const warn = vi.spyOn(log, "warn").mockImplementation(() => {});
271+
scanCommits([{ sha: "c1", message: "chore[notanid]: bump" }]);
272+
expect(warn).not.toHaveBeenCalled();
273+
warn.mockRestore();
274+
});
275+
});
231276
});

0 commit comments

Comments
 (0)