Skip to content

Commit 36af592

Browse files
committed
Add --include-messages option to filter commits by subject regex
1 parent 4e382c4 commit 36af592

7 files changed

Lines changed: 146 additions & 22 deletions

File tree

README.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,17 @@ linear-release update --stage="in review" --name="Release 1.2.0"
149149

150150
### CLI Options
151151

152-
| Option | Commands | Description |
153-
| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154-
| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. |
155-
| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. |
156-
| `--stage` | `update` | Target deployment stage (required for `update`) |
157-
| `--include-paths` | `sync` | Filter commits by changed file paths |
158-
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
159-
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
160-
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
161-
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
152+
| Option | Commands | Description |
153+
| -------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154+
| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. |
155+
| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. |
156+
| `--stage` | `update` | Target deployment stage (required for `update`) |
157+
| `--include-paths` | `sync` | Filter commits by changed file paths |
158+
| `--include-messages` | `sync` | Filter commits whose subject (first line) matches a regex |
159+
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
160+
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
161+
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
162+
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
162163

163164
### Command Targeting
164165

@@ -209,6 +210,22 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g
209210

210211
Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence.
211212

213+
### Commit Message Filtering
214+
215+
Use `--include-messages` to only scan commits whose subject (first line) matches a regular expression. Useful when the default commit range pulls in noise — direct pushes without issue links, bot commits, or merge commits you don't want appearing in releases.
216+
217+
```bash
218+
# Only commits that mention a Linear issue identifier in the subject
219+
linear-release sync --include-messages="[A-Z]{2,}-[0-9]+"
220+
221+
# Conventional Commits — keep user-impacting changes, drop chore/docs/test/ci
222+
linear-release sync --include-messages="^(feat|fix|perf):"
223+
```
224+
225+
The regex is matched against the commit subject only (everything before the first newline) — body lines such as squash dumps or co-author trailers are ignored. Use the regex's own `|` alternation to combine multiple patterns; remember to escape regex metacharacters in shell strings.
226+
227+
`--include-messages` composes with `--include-paths`: a commit must pass both filters to be scanned.
228+
212229
## How It Works
213230

214231
1. **Fetches the latest release** from your Linear pipeline to determine the commit range

src/args.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ describe("parseCLIArgs", () => {
8181
expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]);
8282
});
8383

84+
it("defaults --include-messages to null", () => {
85+
const result = parseCLIArgs([]);
86+
expect(result.includeMessages).toBeNull();
87+
});
88+
89+
it("returns --include-messages as the raw pattern string", () => {
90+
const result = parseCLIArgs(["--include-messages", "^(feat|fix):"]);
91+
expect(result.includeMessages).toBe("^(feat|fix):");
92+
});
93+
94+
it("treats empty --include-messages as no filter", () => {
95+
const result = parseCLIArgs(["--include-messages", ""]);
96+
expect(result.includeMessages).toBeNull();
97+
});
98+
99+
it("throws a helpful error on invalid --include-messages regex", () => {
100+
expect(() => parseCLIArgs(["--include-messages", "([unclosed"])).toThrow(/Invalid --include-messages regex/);
101+
});
102+
84103
it("throws on unknown flags (strict mode)", () => {
85104
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
86105
});

src/args.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type ParsedCLIArgs = {
77
releaseVersion?: string;
88
stageName?: string;
99
includePaths: string[];
10+
includeMessages: string | null;
1011
jsonOutput: boolean;
1112
timeoutSeconds: number;
1213
logLevel: LogLevel;
@@ -20,6 +21,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
2021
"release-version": { type: "string" },
2122
stage: { type: "string" },
2223
"include-paths": { type: "string" },
24+
"include-messages": { type: "string" },
2325
json: { type: "boolean", default: false },
2426
timeout: { type: "string" },
2527
quiet: { type: "boolean", default: false },
@@ -47,6 +49,18 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
4749
if (values.quiet) logLevel = LogLevel.Quiet;
4850
else if (values.verbose) logLevel = LogLevel.Verbose;
4951

52+
let includeMessages: string | null = null;
53+
const rawIncludeMessages = values["include-messages"];
54+
if (rawIncludeMessages !== undefined && rawIncludeMessages.length > 0) {
55+
try {
56+
new RegExp(rawIncludeMessages);
57+
} catch (err) {
58+
const detail = err instanceof Error ? err.message : String(err);
59+
throw new Error(`Invalid --include-messages regex: ${detail}`);
60+
}
61+
includeMessages = rawIncludeMessages;
62+
}
63+
5064
return {
5165
command: positionals[0] || "sync",
5266
releaseName: values.name,
@@ -58,6 +72,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
5872
.map((p) => p.trim())
5973
.filter((p) => p.length > 0)
6074
: [],
75+
includeMessages,
6176
jsonOutput: values.json ?? false,
6277
timeoutSeconds,
6378
logLevel,

src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Options:
5252
--release-version=<version> Release version identifier
5353
--stage=<stage> Deployment stage (required for update)
5454
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
55+
--include-messages=<regex> Filter commits whose subject (first line) matches the regex
5556
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
5657
--json Output result as JSON (logs emitted as JSON Lines on stderr)
5758
--quiet Suppress info-level output (warnings and errors still printed)
@@ -68,6 +69,7 @@ Examples:
6869
linear-release complete
6970
linear-release update --stage=production
7071
linear-release sync --include-paths="apps/web/**,packages/**"
72+
linear-release sync --include-messages="[A-Z]{2,}-[0-9]+"
7173
`);
7274
process.exit(0);
7375
}
@@ -87,8 +89,17 @@ try {
8789
error(`${message} (run linear-release --help for usage)`);
8890
process.exit(1);
8991
}
90-
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
91-
parsedArgs;
92+
const {
93+
command,
94+
releaseName,
95+
releaseVersion,
96+
stageName,
97+
includePaths,
98+
includeMessages,
99+
jsonOutput,
100+
timeoutSeconds,
101+
logLevel,
102+
} = parsedArgs;
92103
const cliWarnings = getCLIWarnings(parsedArgs);
93104
setLogLevel(logLevel);
94105
if (jsonOutput) {
@@ -214,6 +225,7 @@ async function syncCommand(): Promise<{
214225
const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(
215226
commits,
216227
effectiveIncludePaths,
228+
includeMessages,
217229
);
218230

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

src/scan.test.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,19 @@ describe("scanCommits", () => {
3434
];
3535

3636
it("adds identifier when last action is re-add", () => {
37-
const result = scanCommits(commits, null);
37+
const result = scanCommits(commits, null, null);
3838
expect(ids(result.issueReferences)).toEqual(["BAC-39"]);
3939
expect(ids(result.revertedIssueReferences)).toEqual([]);
4040
});
4141

4242
it("adds identifier when only add commits are present", () => {
43-
const result = scanCommits(commits.slice(0, 2), null);
43+
const result = scanCommits(commits.slice(0, 2), null, null);
4444
expect(ids(result.issueReferences)).toEqual(["BAC-39"]);
4545
expect(ids(result.revertedIssueReferences)).toEqual([]);
4646
});
4747

4848
it("reverts identifier when add is followed by revert", () => {
49-
const result = scanCommits(commits.slice(0, 4), null);
49+
const result = scanCommits(commits.slice(0, 4), null, null);
5050
expect(ids(result.issueReferences)).toEqual([]);
5151
expect(ids(result.revertedIssueReferences)).toEqual(["BAC-39"]);
5252
});
@@ -64,7 +64,7 @@ describe("scanCommits", () => {
6464
message: 'Revert "Fixes DRIVE-320: memory leak in background location service"',
6565
},
6666
];
67-
const result = scanCommits(commits, null);
67+
const result = scanCommits(commits, null, null);
6868
expect(ids(result.issueReferences)).toEqual([]);
6969
expect(ids(result.revertedIssueReferences)).toEqual(["DRIVE-320"]);
7070
});
@@ -74,7 +74,7 @@ describe("scanCommits", () => {
7474
{ sha: "a1", message: "Bump v1-2 to v1-3" },
7575
{ sha: "r1", message: 'Revert "Bump v1-2 to v1-3"' },
7676
];
77-
const result = scanCommits(commits, null);
77+
const result = scanCommits(commits, null, null);
7878
expect(ids(result.issueReferences)).toEqual([]);
7979
expect(ids(result.revertedIssueReferences)).toEqual([]);
8080
});
@@ -90,7 +90,7 @@ describe("scanCommits", () => {
9090
message: 'Revert "ENG-200: something"',
9191
},
9292
];
93-
const result = scanCommits(commits, null);
93+
const result = scanCommits(commits, null, null);
9494
expect(ids(result.issueReferences)).toEqual(["ENG-100"]);
9595
expect(ids(result.revertedIssueReferences)).toEqual(["ENG-200"]);
9696
});
@@ -104,7 +104,7 @@ describe("scanCommits", () => {
104104
message: 'Revert "ENG-100: fix"',
105105
},
106106
];
107-
const result = scanCommits(commits, null);
107+
const result = scanCommits(commits, null, null);
108108
expect(ids(result.issueReferences)).toEqual([]);
109109
expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]);
110110
});
@@ -118,7 +118,7 @@ describe("scanCommits", () => {
118118
},
119119
{ sha: "a1", branchName: "user/eng-100" },
120120
];
121-
const result = scanCommits(commits, null);
121+
const result = scanCommits(commits, null, null);
122122
expect(ids(result.issueReferences)).toEqual(["ENG-100"]);
123123
expect(ids(result.revertedIssueReferences)).toEqual([]);
124124
});
@@ -136,7 +136,7 @@ describe("scanCommits", () => {
136136
message: "Merge pull request #2\n\nFixes ENG-200",
137137
},
138138
];
139-
const result = scanCommits(commits, null);
139+
const result = scanCommits(commits, null, null);
140140
expect(ids(result.issueReferences)).toEqual(["ENG-200"]);
141141
expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]);
142142
});
@@ -146,9 +146,58 @@ describe("scanCommits", () => {
146146
{ sha: "a1", branchName: "user/eng-100", message: "Fixes ENG-100" },
147147
{ sha: "r1", message: 'Revert "Fixes ENG-100"' },
148148
];
149-
const result = scanCommits(commits, null);
149+
const result = scanCommits(commits, null, null);
150150
expect(ids(result.issueReferences)).toEqual([]);
151151
expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]);
152152
});
153153
});
154+
155+
describe("--include-messages filter", () => {
156+
it("includes only commits whose subject matches the regex", () => {
157+
const commits: CommitContext[] = [
158+
{ sha: "c1", message: "feat: add login. Fixes ENG-100" },
159+
{ sha: "c2", message: "chore: bump deps. Fixes ENG-200" },
160+
{ sha: "c3", message: "fix: handle null. Fixes ENG-300" },
161+
];
162+
const result = scanCommits(commits, null, "^(feat|fix):");
163+
expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-300"]);
164+
expect(result.debugSink.inspectedShas).toEqual(["c1", "c3"]);
165+
});
166+
167+
it("matches against the subject (first line) only, ignoring body", () => {
168+
const commits: CommitContext[] = [{ sha: "c1", message: "chore: tidy\n\nfeat: ENG-100 add login (in body)" }];
169+
const result = scanCommits(commits, null, "^feat:");
170+
expect(ids(result.issueReferences)).toEqual([]);
171+
expect(result.debugSink.inspectedShas).toEqual([]);
172+
});
173+
174+
it("supports unanchored substring patterns", () => {
175+
const commits: CommitContext[] = [
176+
{ sha: "c1", message: "Squash: feat. Fixes ENG-100" },
177+
{ sha: "c2", message: "chore: bump" },
178+
];
179+
const result = scanCommits(commits, null, "feat");
180+
expect(ids(result.issueReferences)).toEqual(["ENG-100"]);
181+
});
182+
183+
it("skips commits with no message when a regex is set", () => {
184+
const commits: CommitContext[] = [
185+
{ sha: "c1", branchName: "user/eng-100", message: null },
186+
{ sha: "c2", branchName: "user/eng-200", message: "feat: add login" },
187+
];
188+
const result = scanCommits(commits, null, "^feat:");
189+
expect(ids(result.issueReferences)).toEqual(["ENG-200"]);
190+
expect(result.debugSink.inspectedShas).toEqual(["c2"]);
191+
});
192+
193+
it("records the pattern on the debug sink", () => {
194+
const result = scanCommits([{ sha: "c1", message: "feat: x" }], null, "^feat:");
195+
expect(result.debugSink.includeMessages).toBe("^feat:");
196+
});
197+
198+
it("leaves includeMessages null when filter is disabled", () => {
199+
const result = scanCommits([{ sha: "c1", message: "anything" }], null, null);
200+
expect(result.debugSink.includeMessages).toBeNull();
201+
});
202+
});
154203
});

src/scan.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t
1414
export function scanCommits(
1515
commits: CommitContext[],
1616
includePaths: string[] | null,
17+
includeMessages: string | null,
1718
): {
1819
issueReferences: IssueReference[];
1920
revertedIssueReferences: IssueReference[];
2021
prNumbers: number[];
2122
debugSink: DebugSink;
2223
} {
24+
const messageRegex = includeMessages ? new RegExp(includeMessages) : null;
2325
const lastAction = new Map<string, "added" | "reverted">();
2426
const addedRefs = new Map<string, IssueReference>();
2527
const revertedRefs = new Map<string, IssueReference>();
@@ -31,9 +33,18 @@ export function scanCommits(
3133
revertedIssues: {},
3234
pullRequests: [],
3335
includePaths,
36+
includeMessages,
3437
};
3538

3639
for (const commit of commits) {
40+
if (messageRegex) {
41+
const subject = (commit.message ?? "").split("\n", 1)[0]!;
42+
if (!messageRegex.test(subject)) {
43+
verbose(`Skipping commit ${commit.sha} — subject does not match --include-messages`);
44+
continue;
45+
}
46+
}
47+
3748
debugSink.inspectedShas.push(commit.sha);
3849

3950
for (const { identifier, source } of extractRevertedIssueIdentifiersForCommit(commit)) {

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,5 @@ export type DebugSink = {
107107
revertedIssues: Record<string, IssueSource[]>; // Issue identifier -> array of sources (reverted)
108108
pullRequests: PullRequestSource[]; // PR numbers found in commits
109109
includePaths: string[] | null; // Path filters applied during commit scanning
110+
includeMessages: string | null; // Commit-message regex source applied during scanning
110111
};

0 commit comments

Comments
 (0)