Skip to content
Closed
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. |
| `--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. |
Expand Down Expand Up @@ -233,6 +234,29 @@ 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

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:

```
feat(routing)[ENG-123]: add stop reordering
fix[ENG-123]: handle empty payload
```

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**:

```bash
# Conventional Commits: type, optional (scope), optional !, then [TEAM-NUMBER]
linear-release sync --issue-pattern="\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]"

# A bespoke convention, e.g. "JIRA: ENG-123 | …"
linear-release sync --issue-pattern="^[A-Z]+:\s+(\w+)-(\d+)"
```

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.

> 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`.

### Release Links

`--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard.
Expand Down
24 changes: 24 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ describe("parseCLIArgs", () => {
expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/);
});

it("defaults --issue-pattern to null", () => {
const result = parseCLIArgs([]);
expect(result.issuePattern).toBeNull();
});

it("returns --issue-pattern as the raw pattern string", () => {
const pattern = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";
const result = parseCLIArgs(["--issue-pattern", pattern]);
expect(result.issuePattern).toBe(pattern);
});

it("treats empty --issue-pattern as no pattern", () => {
const result = parseCLIArgs(["--issue-pattern", ""]);
expect(result.issuePattern).toBeNull();
});

it("throws a helpful error on invalid --issue-pattern regex", () => {
expect(() => parseCLIArgs(["--issue-pattern", "([unclosed"])).toThrow(/Invalid --issue-pattern regex/);
});

it("throws when --issue-pattern has fewer than two capturing groups", () => {
expect(() => parseCLIArgs(["--issue-pattern", "\\[(\\w+-\\d+)\\]"])).toThrow(/at least two capturing groups/);
});

it("parses repeatable --link values", () => {
const result = parseCLIArgs([
"sync",
Expand Down
36 changes: 36 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type ParsedCLIArgs = {
baseRef?: string;
includePaths: string[];
includeSubjects: string | null;
issuePattern: string | null;
links: ReleaseLink[];
documents: ReleaseDocumentSpec[];
releaseNotes?: ReleaseNoteSpec;
Expand Down Expand Up @@ -71,6 +72,21 @@ function parseAbsoluteUrl(value: string): URL | undefined {
}
}

/**
* Counts capturing groups in an already-valid regex source by appending an
* empty alternative (`|`) — which forces a match against the empty string — and
* reading the result arity. Returns Infinity if the probe can't be built, so a
* regex that already compiled is never wrongly rejected for group count.
*/
function countCapturingGroups(source: string): number {
try {
const probe = new RegExp(`${source}|`);
return (probe.exec("")?.length ?? 1) - 1;
} catch {
return Number.POSITIVE_INFINITY;
}
}

/** Splits `Title=value` once on `=`. Title is trimmed; value is returned verbatim so markdown whitespace survives. */
function splitTitleAndValue(raw: string, flag: string): { title: string; value: string } {
const separatorIndex = raw.indexOf("=");
Expand Down Expand Up @@ -133,6 +149,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 },
Expand Down Expand Up @@ -178,6 +195,24 @@ 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 (countCapturingGroups(rawIssuePattern) < 2) {
throw new Error(
`Invalid --issue-pattern: regex must have at least two capturing groups — group 1 for the team key and group 2 for the issue number ` +
`(e.g. "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]" for Conventional Commits).`,
);
}
issuePattern = rawIssuePattern;
}
const command = positionals[0] || "sync";
const links = (values.link ?? []).map(parseReleaseLink);

Expand Down Expand Up @@ -226,6 +261,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
.filter((p) => p.length > 0)
: [],
includeSubjects,
issuePattern,
links,
documents,
releaseNotes,
Expand Down
71 changes: 71 additions & 0 deletions src/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,3 +895,74 @@ describe("extractPullRequestNumbersForCommit — GitLab merge request trailer",
expect(result).toEqual([]);
});
});

describe("custom issue pattern (--issue-pattern)", () => {
// Conventional Commits: the identifier follows the type and optional scope, so
// the built-in start-anchored subject patterns do not see it.
const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";

it.each([
["with scope", "feat(routing)[ENG-123]: add stop reordering", "ENG-123"],
["without scope", "fix[ENG-123]: handle empty payload", "ENG-123"],
["with breaking-change bang", "feat(api)![ENG-7]: drop v1", "ENG-7"],
])("extracts a Conventional Commits identifier %s", (_name, message, expected) => {
const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message }, { issuePattern: CONVENTIONAL });
expect(ids(result)).toEqual([expected]);
expect(result[0]!.source).toBe("commit_message");
});

it("is not applied when no pattern is provided", () => {
const result = extractLinearIssueIdentifiersForCommit({ sha: "abc", message: "feat(routing)[ENG-123]: x" });
expect(ids(result)).toEqual([]);
});

it("normalizes case and strips leading zeros on the number", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", message: "feat[eng-45]: lowercase team key" },
{ issuePattern: CONVENTIONAL },
);
expect(ids(result)).toEqual(["ENG-45"]);
});

it("rejects identifiers whose number has a leading zero", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", message: "feat[ENG-007]: bond" },
{ issuePattern: CONVENTIONAL },
);
expect(ids(result)).toEqual([]);
});

it("extracts multiple identifiers from one subject", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", message: "feat[ENG-1] feat[ENG-2]: two" },
{ issuePattern: CONVENTIONAL },
);
expect(ids(result)).toEqual(["ENG-1", "ENG-2"]);
});

it("only scans the subject, not the body", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", message: "chore: bump\n\nfeat[ENG-9]: stale body reference" },
{ issuePattern: CONVENTIONAL },
);
expect(ids(result)).toEqual([]);
});

it("composes with branch-name extraction without duplicating", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", branchName: "feature/ENG-123-x", message: "feat[ENG-123]: same issue" },
{ issuePattern: CONVENTIONAL },
);
expect(ids(result)).toEqual(["ENG-123"]);
// Branch name wins the source since it is scanned first.
expect(result[0]!.source).toBe("branch_name");
});

it("does not loop forever on a zero-width-capable pattern", () => {
const result = extractLinearIssueIdentifiersForCommit(
{ sha: "abc", message: "ENG-1 ENG-2" },
{ issuePattern: "(\\w*)-(\\d*)" },
);
expect(ids(result)).toEqual(["ENG-1", "ENG-2"]);
});
});
57 changes: 55 additions & 2 deletions src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,43 @@ function matchCommonSubjectPatterns(message: string): IdentifierMatch[] {
return results;
}

/**
* Extract identifiers from a user-supplied subject pattern (the `--issue-pattern`
* flag). The regex must capture the team key in group 1 and the issue number in
* group 2. It is matched against the commit subject (first line) and scanned
* globally, so a single subject can carry more than one identifier.
*
* The flag exists for subject conventions the built-in patterns don't recognize
* — most commonly Conventional Commits, where the identifier follows the type and
* scope (`feat(scope)[ENG-123]: …`, `fix[ENG-123]: …`) rather than leading the
* subject. Example pattern: `\w+(?:\([^)]*\))?!?\[(\w+)-(\d+)\]`.
*/
function matchCustomSubjectPattern(message: string, pattern: string | null | undefined): IdentifierMatch[] {
if (!pattern) return [];

const subject = getCommitSubject(message);
// Force global so the loop advances; force case-insensitive to match the
// built-in identifier regexes. The team key is uppercased on output anyway.
const regex = new RegExp(pattern, "gi");
const results: IdentifierMatch[] = [];
let match;
while ((match = regex.exec(subject)) !== null) {
// A pattern that can match empty (e.g. `(\w*)-(\d*)`) would loop forever.
if (match.index === regex.lastIndex) regex.lastIndex++;

const [, teamKey, numberString] = match;
if (!teamKey || !numberString || !/^[0-9]+$/.test(numberString)) continue;
// Reject leading zeros (e.g. ENG-0004), matching parseMatch.
if (Number(numberString).toString().length !== numberString.length) continue;

results.push({
rawIdentifier: `${teamKey}-${numberString}`,
identifier: `${teamKey.toUpperCase()}-${Number(numberString)}`,
});
}
return results;
}

/**
* Extract issue identifiers from text only when preceded by a magic word.
* Processes text line-by-line, matching Linear's detection behavior.
Expand Down Expand Up @@ -230,7 +267,19 @@ export type ExtractedIdentifier = {
source: "branch_name" | "commit_message";
};

export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] {
export type ExtractOptions = {
/**
* User-supplied regex (source string) capturing the team key in group 1 and
* the issue number in group 2. Applied to the commit subject. See the
* `--issue-pattern` flag and matchCustomSubjectPattern.
*/
issuePattern?: string | null;
};

export function extractLinearIssueIdentifiersForCommit(
commit: CommitContext,
options: ExtractOptions = {},
): ExtractedIdentifier[] {
if (!commit) {
return [];
}
Expand Down Expand Up @@ -267,7 +316,11 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E
const scanTarget = messageDepth % 2 === 1 ? afterTitle : (commit.message ?? "");
const message = stripSquashBlock(scanTarget);
if (message.length > 0) {
for (const match of [...matchCommonSubjectPatterns(message), ...matchMagicWordIdentifiers(message)]) {
for (const match of [
...matchCommonSubjectPatterns(message),
...matchCustomSubjectPattern(message, options.issuePattern),
...matchMagicWordIdentifiers(message),
]) {
if (!found.has(match.identifier)) {
found.set(match.identifier, {
identifier: match.identifier,
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Options:
--stage=<stage> Deployment stage (required for update)
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
--include-subjects=<regex> Filter commits whose subject (first line) matches the regex
--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
--link <URL|Label=URL> Add a link to the targeted release (repeatable)
--document <Title=content> 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)
Expand All @@ -83,6 +84,7 @@ Examples:
linear-release update --stage=production
linear-release sync --include-paths="apps/web/**,packages/**"
linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+"
linear-release sync --issue-pattern="\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]"
linear-release sync --link "https://ci.example.com/run/123"
linear-release sync --link "Pipeline=https://ci.example.com/run/123"
linear-release sync --document-file "Changelog=./CHANGELOG.md"
Expand Down Expand Up @@ -116,6 +118,7 @@ const {
baseRef,
includePaths,
includeSubjects,
issuePattern,
links,
documents: documentSpecs,
releaseNotes: releaseNotesSpec,
Expand Down Expand Up @@ -350,6 +353,7 @@ async function syncCommand(): Promise<{
const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, {
includePaths: effectiveIncludePaths,
includeSubjects,
issuePattern,
});

verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`);
Expand Down
29 changes: 29 additions & 0 deletions src/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,33 @@ describe("scanCommits", () => {
expect(ids(result.revertedIssueReferences)).toEqual([]);
});
});

describe("--issue-pattern extraction", () => {
const CONVENTIONAL = "\\w+(?:\\([^)]*\\))?!?\\[(\\w+)-(\\d+)\\]";

it("extracts identifiers from Conventional Commits subjects", () => {
const commits: CommitContext[] = [
{ sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" },
{ sha: "c2", message: "fix[ENG-200]: handle empty payload" },
];
const result = scanCommits(commits, { issuePattern: CONVENTIONAL });
expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-200"]);
});

it("extracts nothing from those subjects without the pattern", () => {
const commits: CommitContext[] = [{ sha: "c1", message: "feat(routing)[ENG-100]: add stop reordering" }];
const result = scanCommits(commits, {});
expect(ids(result.issueReferences)).toEqual([]);
});

it("records the pattern on the debug sink", () => {
const result = scanCommits([{ sha: "c1", message: "feat[ENG-1]: x" }], { issuePattern: CONVENTIONAL });
expect(result.debugSink.issuePattern).toBe(CONVENTIONAL);
});

it("leaves issuePattern null when not provided", () => {
const result = scanCommits([{ sha: "c1", message: "anything" }], {});
expect(result.debugSink.issuePattern).toBeNull();
});
});
});
6 changes: 4 additions & 2 deletions src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t
export type ScanOptions = {
includePaths?: string[] | null;
includeSubjects?: string | null;
issuePattern?: string | null;
};

/**
Expand All @@ -26,7 +27,7 @@ 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 lastAction = new Map<string, "added" | "reverted">();
const addedRefs = new Map<string, IssueReference>();
Expand All @@ -40,6 +41,7 @@ export function scanCommits(
pullRequests: [],
includePaths,
includeSubjects,
issuePattern,
};

for (const commit of commits) {
Expand Down Expand Up @@ -68,7 +70,7 @@ 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 })) {
if (!debugSink.issues[identifier]) {
debugSink.issues[identifier] = [];
}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; // Custom issue-identifier regex source applied during scanning
};
Loading