Skip to content

Commit ccefade

Browse files
olsavmicclaude
andcommitted
feat: add --issue-pattern to extract issue IDs from custom subject formats
Built-in subject detection only matches identifiers that lead the subject (`[ENG-123] …`, `(ENG-123) …`, `ENG-123: …`). Conventional Commits put the identifier after the type and optional scope (`feat(scope)[ENG-123]: …`, `fix[ENG-123]: …`), so it is never linked to the release. `--include-subjects` is a filter, not an extractor, so it cannot recover the identifier either. Add a `--issue-pattern=<regex>` flag (group 1 = team key, group 2 = issue number) matched against the commit subject. Matching is global and case-insensitive; the team key is upper-cased and leading zeros stripped, leading-zero numbers rejected. It is additive to and de-duplicated with the existing branch-name / magic-word / built-in-subject detection. The flag is validated at parse time to compile and expose at least two capture groups. Closes #106 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bbeffdf commit ccefade

9 files changed

Lines changed: 248 additions & 4 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
156156
| `--stage` | `update` | Target deployment stage (required for `update`) |
157157
| `--include-paths` | `sync` | Filter commits by changed file paths |
158158
| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex |
159+
| `--issue-pattern` | `sync` | Extract issue identifiers from a custom subject format via a regex (group 1 = team key, group 2 = issue number). Use for conventions the built-in detection misses, e.g. Conventional Commits. |
159160
| `--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. |
160161
| `--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. |
161162
| `--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,29 @@ The regex is matched against the commit subject only (everything before the firs
233234

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

237+
### Custom Issue Patterns
238+
239+
Out of the box, identifiers are detected from branch names, magic words (`Fixes ENG-123`), and common subject conventions where the identifier leads the subject — `[ENG-123] …`, `(ENG-123) …`, `ENG-123: …`. Some teams instead put the identifier _after_ a [Conventional Commits](https://www.conventionalcommits.org/) type and scope, which the built-in patterns don't recognize:
240+
241+
```
242+
feat(routing)[ENG-123]: add stop reordering
243+
fix[ENG-123]: handle empty payload
244+
```
245+
246+
Use `--issue-pattern` to teach the scanner your convention. The flag takes a regex whose **first capture group is the team key** and **second capture group is the issue number**:
247+
248+
```bash
249+
# Conventional Commits: type, optional (scope), optional !, then [TEAM-NUMBER]
250+
linear-release sync --issue-pattern="\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]"
251+
252+
# A bespoke convention, e.g. "JIRA: ENG-123 | …"
253+
linear-release sync --issue-pattern="^[A-Z]+:\s+(\w+)-(\d+)"
254+
```
255+
256+
The pattern is matched against the commit subject (first line) and scanned globally, so a subject may carry more than one identifier. Matching is case-insensitive; the team key is upper-cased and leading zeros on the number are stripped (`eng-0045``ENG-45`), and numbers written with a leading zero are rejected as invalid identifiers. Matches are merged with the built-in detection rather than replacing it, and de-duplicated across branch name and message.
257+
258+
> The CLI validates that the regex compiles and has at least two capturing groups, but it cannot know which substrings you intend as the team key and number — double-check the capture groups against a sample commit with `--dry-run --verbose`.
259+
236260
### Release Links
237261

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

src/args.test.ts

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

123+
it("defaults --issue-pattern to null", () => {
124+
const result = parseCLIArgs([]);
125+
expect(result.issuePattern).toBeNull();
126+
});
127+
128+
it("returns --issue-pattern as the raw pattern string", () => {
129+
const pattern = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";
130+
const result = parseCLIArgs(["--issue-pattern", pattern]);
131+
expect(result.issuePattern).toBe(pattern);
132+
});
133+
134+
it("treats empty --issue-pattern as no pattern", () => {
135+
const result = parseCLIArgs(["--issue-pattern", ""]);
136+
expect(result.issuePattern).toBeNull();
137+
});
138+
139+
it("throws a helpful error on invalid --issue-pattern regex", () => {
140+
expect(() => parseCLIArgs(["--issue-pattern", "([unclosed"])).toThrow(/Invalid --issue-pattern regex/);
141+
});
142+
143+
it("throws when --issue-pattern has fewer than two capturing groups", () => {
144+
expect(() => parseCLIArgs(["--issue-pattern", "\\[(\\w+-\\d+)\\]"])).toThrow(/at least two capturing groups/);
145+
});
146+
123147
it("parses repeatable --link values", () => {
124148
const result = parseCLIArgs([
125149
"sync",

src/args.ts

Lines changed: 36 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;
@@ -71,6 +72,21 @@ function parseAbsoluteUrl(value: string): URL | undefined {
7172
}
7273
}
7374

75+
/**
76+
* Counts capturing groups in an already-valid regex source by appending an
77+
* empty alternative (`|`) — which forces a match against the empty string — and
78+
* reading the result arity. Returns Infinity if the probe can't be built, so a
79+
* regex that already compiled is never wrongly rejected for group count.
80+
*/
81+
function countCapturingGroups(source: string): number {
82+
try {
83+
const probe = new RegExp(`${source}|`);
84+
return (probe.exec("")?.length ?? 1) - 1;
85+
} catch {
86+
return Number.POSITIVE_INFINITY;
87+
}
88+
}
89+
7490
/** Splits `Title=value` once on `=`. Title is trimmed; value is returned verbatim so markdown whitespace survives. */
7591
function splitTitleAndValue(raw: string, flag: string): { title: string; value: string } {
7692
const separatorIndex = raw.indexOf("=");
@@ -133,6 +149,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
133149
"base-ref": { type: "string" },
134150
"include-paths": { type: "string" },
135151
"include-subjects": { type: "string" },
152+
"issue-pattern": { type: "string" },
136153
link: { type: "string", multiple: true },
137154
document: { type: "string", multiple: true },
138155
"document-file": { type: "string", multiple: true },
@@ -178,6 +195,24 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
178195
}
179196
includeSubjects = rawIncludeSubjects;
180197
}
198+
199+
let issuePattern: string | null = null;
200+
const rawIssuePattern = values["issue-pattern"];
201+
if (rawIssuePattern !== undefined && rawIssuePattern.length > 0) {
202+
try {
203+
new RegExp(rawIssuePattern);
204+
} catch (err) {
205+
const detail = err instanceof Error ? err.message : String(err);
206+
throw new Error(`Invalid --issue-pattern regex: ${detail}`);
207+
}
208+
if (countCapturingGroups(rawIssuePattern) < 2) {
209+
throw new Error(
210+
`Invalid --issue-pattern: regex must have at least two capturing groups — group 1 for the team key and group 2 for the issue number ` +
211+
`(e.g. "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]" for Conventional Commits).`,
212+
);
213+
}
214+
issuePattern = rawIssuePattern;
215+
}
181216
const command = positionals[0] || "sync";
182217
const links = (values.link ?? []).map(parseReleaseLink);
183218

@@ -226,6 +261,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
226261
.filter((p) => p.length > 0)
227262
: [],
228263
includeSubjects,
264+
issuePattern,
229265
links,
230266
documents,
231267
releaseNotes,

src/extractors.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,3 +895,74 @@ describe("extractPullRequestNumbersForCommit — GitLab merge request trailer",
895895
expect(result).toEqual([]);
896896
});
897897
});
898+
899+
describe("custom issue pattern (--issue-pattern)", () => {
900+
// Conventional Commits: the identifier follows the type and optional scope, so
901+
// the built-in start-anchored subject patterns do not see it.
902+
const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";
903+
904+
it.each([
905+
["with scope", "feat(routing)[ENG-123]: add stop reordering", "ENG-123"],
906+
["without scope", "fix[ENG-123]: handle empty payload", "ENG-123"],
907+
["with breaking-change bang", "feat(api)![ENG-7]: drop v1", "ENG-7"],
908+
])("extracts a Conventional Commits identifier %s", (_name, message, expected) => {
909+
const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message }, { issuePattern: CONVENTIONAL });
910+
expect(ids(result)).toEqual([expected]);
911+
expect(result[0]!.source).toBe("commit_message");
912+
});
913+
914+
it("is not applied when no pattern is provided", () => {
915+
const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message: "feat(routing)[ENG-123]: x" });
916+
expect(ids(result)).toEqual([]);
917+
});
918+
919+
it("normalizes case and strips leading zeros on the number", () => {
920+
const result = extractLinearIssueIdentifiersForCommit(
921+
{ sha: "abc", message: "feat[eng-45]: lowercase team key" },
922+
{ issuePattern: CONVENTIONAL },
923+
);
924+
expect(ids(result)).toEqual(["ENG-45"]);
925+
});
926+
927+
it("rejects identifiers whose number has a leading zero", () => {
928+
const result = extractLinearIssueIdentifiersForCommit(
929+
{ sha: "abc", message: "feat[ENG-007]: bond" },
930+
{ issuePattern: CONVENTIONAL },
931+
);
932+
expect(ids(result)).toEqual([]);
933+
});
934+
935+
it("extracts multiple identifiers from one subject", () => {
936+
const result = extractLinearIssueIdentifiersForCommit(
937+
{ sha: "abc", message: "feat[ENG-1] feat[ENG-2]: two" },
938+
{ issuePattern: CONVENTIONAL },
939+
);
940+
expect(ids(result)).toEqual(["ENG-1", "ENG-2"]);
941+
});
942+
943+
it("only scans the subject, not the body", () => {
944+
const result = extractLinearIssueIdentifiersForCommit(
945+
{ sha: "abc", message: "chore: bump\n\nfeat[ENG-9]: stale body reference" },
946+
{ issuePattern: CONVENTIONAL },
947+
);
948+
expect(ids(result)).toEqual([]);
949+
});
950+
951+
it("composes with branch-name extraction without duplicating", () => {
952+
const result = extractLinearIssueIdentifiersForCommit(
953+
{ sha: "abc", branchName: "feature/ENG-123-x", message: "feat[ENG-123]: same issue" },
954+
{ issuePattern: CONVENTIONAL },
955+
);
956+
expect(ids(result)).toEqual(["ENG-123"]);
957+
// Branch name wins the source since it is scanned first.
958+
expect(result[0]!.source).toBe("branch_name");
959+
});
960+
961+
it("does not loop forever on a zero-width-capable pattern", () => {
962+
const result = extractLinearIssueIdentifiersForCommit(
963+
{ sha: "abc", message: "ENG-1 ENG-2" },
964+
{ issuePattern: "(\\w*)-(\\d*)" },
965+
);
966+
expect(ids(result)).toEqual(["ENG-1", "ENG-2"]);
967+
});
968+
});

src/extractors.ts

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

203+
/**
204+
* Extract identifiers from a user-supplied subject pattern (the `--issue-pattern`
205+
* flag). The regex must capture the team key in group 1 and the issue number in
206+
* group 2. It is matched against the commit subject (first line) and scanned
207+
* globally, so a single subject can carry more than one identifier.
208+
*
209+
* The flag exists for subject conventions the built-in patterns don't recognize
210+
* — most commonly Conventional Commits, where the identifier follows the type and
211+
* scope (`feat(scope)[ENG-123]: …`, `fix[ENG-123]: …`) rather than leading the
212+
* subject. Example pattern: `\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]`.
213+
*/
214+
function matchCustomSubjectPattern(message: string, pattern: string | null | undefined): IdentifierMatch[] {
215+
if (!pattern) return [];
216+
217+
const subject = getCommitSubject(message);
218+
// Force global so the loop advances; force case-insensitive to match the
219+
// built-in identifier regexes. The team key is uppercased on output anyway.
220+
const regex = new RegExp(pattern, "gi");
221+
const results: IdentifierMatch[] = [];
222+
let match;
223+
while ((match = regex.exec(subject)) !== null) {
224+
// A pattern that can match empty (e.g. `(\w*)-(\d*)`) would loop forever.
225+
if (match.index === regex.lastIndex) regex.lastIndex++;
226+
227+
const [, teamKey, numberString] = match;
228+
if (!teamKey || !numberString || !/^[0-9]+$/.test(numberString)) continue;
229+
// Reject leading zeros (e.g. ENG-0004), matching parseMatch.
230+
if (Number(numberString).toString().length !== numberString.length) continue;
231+
232+
results.push({
233+
rawIdentifier: `${teamKey}-${numberString}`,
234+
identifier: `${teamKey.toUpperCase()}-${Number(numberString)}`,
235+
});
236+
}
237+
return results;
238+
}
239+
203240
/**
204241
* Extract issue identifiers from text only when preceded by a magic word.
205242
* Processes text line-by-line, matching Linear's detection behavior.
@@ -230,7 +267,19 @@ export type ExtractedIdentifier = {
230267
source: "branch_name" | "commit_message";
231268
};
232269

233-
export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] {
270+
export type ExtractOptions = {
271+
/**
272+
* User-supplied regex (source string) capturing the team key in group 1 and
273+
* the issue number in group 2. Applied to the commit subject. See the
274+
* `--issue-pattern` flag and matchCustomSubjectPattern.
275+
*/
276+
issuePattern?: string | null;
277+
};
278+
279+
export function extractLinearIssueIdentifiersForCommit(
280+
commit: CommitContext,
281+
options: ExtractOptions = {},
282+
): ExtractedIdentifier[] {
234283
if (!commit) {
235284
return [];
236285
}
@@ -267,7 +316,11 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E
267316
const scanTarget = messageDepth % 2 === 1 ? afterTitle : (commit.message ?? "");
268317
const message = stripSquashBlock(scanTarget);
269318
if (message.length > 0) {
270-
for (const match of [...matchCommonSubjectPatterns(message), ...matchMagicWordIdentifiers(message)]) {
319+
for (const match of [
320+
...matchCommonSubjectPatterns(message),
321+
...matchCustomSubjectPattern(message, options.issuePattern),
322+
...matchMagicWordIdentifiers(message),
323+
]) {
271324
if (!found.has(match.identifier)) {
272325
found.set(match.identifier, {
273326
identifier: match.identifier,

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Options:
5959
--stage=<stage> Deployment stage (required for update)
6060
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
6161
--include-subjects=<regex> Filter commits whose subject (first line) matches the regex
62+
--issue-pattern=<regex> Extract issue identifiers from a custom subject format; capture the team key in group 1 and the issue number in group 2
6263
--link <URL|Label=URL> Add a link to the targeted release (repeatable)
6364
--document <Title=content> Attach a document to the release (repeatable, Title required)
6465
--document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable)
@@ -83,6 +84,7 @@ Examples:
8384
linear-release update --stage=production
8485
linear-release sync --include-paths="apps/web/**,packages/**"
8586
linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+"
87+
linear-release sync --issue-pattern="\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]"
8688
linear-release sync --link "https://ci.example.com/run/123"
8789
linear-release sync --link "Pipeline=https://ci.example.com/run/123"
8890
linear-release sync --document-file "Changelog=./CHANGELOG.md"
@@ -116,6 +118,7 @@ const {
116118
baseRef,
117119
includePaths,
118120
includeSubjects,
121+
issuePattern,
119122
links,
120123
documents: documentSpecs,
121124
releaseNotes: releaseNotesSpec,
@@ -350,6 +353,7 @@ async function syncCommand(): Promise<{
350353
const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, {
351354
includePaths: effectiveIncludePaths,
352355
includeSubjects,
356+
issuePattern,
353357
});
354358

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

src/scan.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,33 @@ describe("scanCommits", () => {
228228
expect(ids(result.revertedIssueReferences)).toEqual([]);
229229
});
230230
});
231+
232+
describe("--issue-pattern extraction", () => {
233+
const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";
234+
235+
it("extracts identifiers from Conventional Commits subjects", () => {
236+
const commits: CommitContext[] = [
237+
{ sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" },
238+
{ sha: "c2", message: "fix[ENG-200]: handle empty payload" },
239+
];
240+
const result = scanCommits(commits, { issuePattern: CONVENTIONAL });
241+
expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-200"]);
242+
});
243+
244+
it("extracts nothing from those subjects without the pattern", () => {
245+
const commits: CommitContext[] = [{ sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" }];
246+
const result = scanCommits(commits, {});
247+
expect(ids(result.issueReferences)).toEqual([]);
248+
});
249+
250+
it("records the pattern on the debug sink", () => {
251+
const result = scanCommits([{ sha: "c1", message: "feat[ENG-1]: x" }], { issuePattern: CONVENTIONAL });
252+
expect(result.debugSink.issuePattern).toBe(CONVENTIONAL);
253+
});
254+
255+
it("leaves issuePattern null when not provided", () => {
256+
const result = scanCommits([{ sha: "c1", message: "anything" }], {});
257+
expect(result.debugSink.issuePattern).toBeNull();
258+
});
259+
});
231260
});

src/scan.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t
1010
export type ScanOptions = {
1111
includePaths?: string[] | null;
1212
includeSubjects?: string | null;
13+
issuePattern?: string | null;
1314
};
1415

1516
/**
@@ -26,7 +27,7 @@ export function scanCommits(
2627
prNumbers: number[];
2728
debugSink: DebugSink;
2829
} {
29-
const { includePaths = null, includeSubjects = null } = options;
30+
const { includePaths = null, includeSubjects = null, issuePattern = null } = options;
3031
const subjectRegex = includeSubjects ? new RegExp(includeSubjects) : null;
3132
const lastAction = new Map<string, "added" | "reverted">();
3233
const addedRefs = new Map<string, IssueReference>();
@@ -40,6 +41,7 @@ export function scanCommits(
4041
pullRequests: [],
4142
includePaths,
4243
includeSubjects,
44+
issuePattern,
4345
};
4446

4547
for (const commit of commits) {
@@ -68,7 +70,7 @@ export function scanCommits(
6870
verbose(`Detected reverted issue key ${identifier} from commit ${commit.sha}`);
6971
}
7072

71-
for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit)) {
73+
for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit, { issuePattern })) {
7274
if (!debugSink.issues[identifier]) {
7375
debugSink.issues[identifier] = [];
7476
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,5 @@ export type DebugSink = {
109109
pullRequests: PullRequestSource[]; // PR numbers found in commits
110110
includePaths: string[] | null; // Path filters applied during commit scanning
111111
includeSubjects: string | null; // Subject regex source applied during scanning
112+
issuePattern: string | null; // Custom issue-identifier regex source applied during scanning
112113
};

0 commit comments

Comments
 (0)