Skip to content
Merged
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
18 changes: 18 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 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. |
Expand Down Expand Up @@ -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): ENG-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.
Expand Down
20 changes: 20 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 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 @@ -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 },
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -226,6 +245,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
.filter((p) => p.length > 0)
: [],
includeSubjects,
issuePattern,
links,
documents,
releaseNotes,
Expand Down
106 changes: 106 additions & 0 deletions src/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(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) => {
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): ENG-621 handle empty payload" },
{ issuePattern: /\b([A-Z]+-\d+)\b/gi },
),
),
).toEqual(["ENG-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({
Expand Down
29 changes: 27 additions & 2 deletions src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 [];
}
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 IDs captured by group 1 from commit subjects (e.g. "\\[([A-Z]+-\\d+)\\]")
--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 Down Expand Up @@ -122,6 +123,7 @@ const {
baseRef,
includePaths,
includeSubjects,
issuePattern,
links,
documents: documentSpecs,
releaseNotes: releaseNotesSpec,
Expand Down Expand Up @@ -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)}`);
Expand Down
47 changes: 46 additions & 1 deletion src/scan.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading