From e75946f4eed1e40e40cb35fb1595bd1ffdc68263 Mon Sep 17 00:00:00 2001 From: autumn-n Date: Sun, 17 May 2026 10:09:08 +0900 Subject: [PATCH 1/5] Add --include-messages option to filter commits by subject regex --- README.md | 37 +++++++++++++++++++------- src/args.test.ts | 19 +++++++++++++ src/args.ts | 15 +++++++++++ src/index.ts | 16 +++++++++-- src/scan.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++------- src/scan.ts | 11 ++++++++ src/types.ts | 1 + 7 files changed, 146 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 24a580a..93d0175 100644 --- a/README.md +++ b/README.md @@ -149,16 +149,17 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### CLI Options -| Option | Commands | Description | -| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--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. | -| `--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. | -| `--stage` | `update` | Target deployment stage (required for `update`) | -| `--include-paths` | `sync` | Filter commits by changed file paths | -| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | -| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | -| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | -| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | +| Option | Commands | Description | +| -------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--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. | +| `--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. | +| `--stage` | `update` | Target deployment stage (required for `update`) | +| `--include-paths` | `sync` | Filter commits by changed file paths | +| `--include-messages` | `sync` | Filter commits whose subject (first line) matches a regex | +| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | +| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | +| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | +| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | ### Command Targeting @@ -209,6 +210,22 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence. +### Commit Message Filtering + +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. + +```bash +# Only commits that mention a Linear issue identifier in the subject +linear-release sync --include-messages="[A-Z]{2,}-[0-9]+" + +# Conventional Commits — keep user-impacting changes, drop chore/docs/test/ci +linear-release sync --include-messages="^(feat|fix|perf):" +``` + +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. + +`--include-messages` composes with `--include-paths`: a commit must pass both filters to be scanned. + ## How It Works 1. **Fetches the latest release** from your Linear pipeline to determine the commit range diff --git a/src/args.test.ts b/src/args.test.ts index 21163b1..923248a 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -81,6 +81,25 @@ describe("parseCLIArgs", () => { expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]); }); + it("defaults --include-messages to null", () => { + const result = parseCLIArgs([]); + expect(result.includeMessages).toBeNull(); + }); + + it("returns --include-messages as the raw pattern string", () => { + const result = parseCLIArgs(["--include-messages", "^(feat|fix):"]); + expect(result.includeMessages).toBe("^(feat|fix):"); + }); + + it("treats empty --include-messages as no filter", () => { + const result = parseCLIArgs(["--include-messages", ""]); + expect(result.includeMessages).toBeNull(); + }); + + it("throws a helpful error on invalid --include-messages regex", () => { + expect(() => parseCLIArgs(["--include-messages", "([unclosed"])).toThrow(/Invalid --include-messages regex/); + }); + it("throws on unknown flags (strict mode)", () => { expect(() => parseCLIArgs(["--unknown-flag"])).toThrow(); }); diff --git a/src/args.ts b/src/args.ts index 86ff11a..0be2f4d 100644 --- a/src/args.ts +++ b/src/args.ts @@ -7,6 +7,7 @@ export type ParsedCLIArgs = { releaseVersion?: string; stageName?: string; includePaths: string[]; + includeMessages: string | null; jsonOutput: boolean; timeoutSeconds: number; logLevel: LogLevel; @@ -20,6 +21,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "release-version": { type: "string" }, stage: { type: "string" }, "include-paths": { type: "string" }, + "include-messages": { type: "string" }, json: { type: "boolean", default: false }, timeout: { type: "string" }, quiet: { type: "boolean", default: false }, @@ -47,6 +49,18 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { if (values.quiet) logLevel = LogLevel.Quiet; else if (values.verbose) logLevel = LogLevel.Verbose; + let includeMessages: string | null = null; + const rawIncludeMessages = values["include-messages"]; + if (rawIncludeMessages !== undefined && rawIncludeMessages.length > 0) { + try { + new RegExp(rawIncludeMessages); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid --include-messages regex: ${detail}`); + } + includeMessages = rawIncludeMessages; + } + return { command: positionals[0] || "sync", releaseName: values.name, @@ -58,6 +72,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .map((p) => p.trim()) .filter((p) => p.length > 0) : [], + includeMessages, jsonOutput: values.json ?? false, timeoutSeconds, logLevel, diff --git a/src/index.ts b/src/index.ts index 3f5b07e..138dec0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ Options: --release-version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) + --include-messages= Filter commits whose subject (first line) matches the regex --timeout= Abort if the operation exceeds this duration (default: 60) --json Output result as JSON (logs emitted as JSON Lines on stderr) --quiet Suppress info-level output (warnings and errors still printed) @@ -67,6 +68,7 @@ Examples: linear-release complete linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" + linear-release sync --include-messages="[A-Z]{2,}-[0-9]+" `); process.exit(0); } @@ -86,8 +88,17 @@ try { error(`${message} (run linear-release --help for usage)`); process.exit(1); } -const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } = - parsedArgs; +const { + command, + releaseName, + releaseVersion, + stageName, + includePaths, + includeMessages, + jsonOutput, + timeoutSeconds, + logLevel, +} = parsedArgs; const cliWarnings = getCLIWarnings(parsedArgs); setLogLevel(logLevel); if (jsonOutput) { @@ -213,6 +224,7 @@ async function syncCommand(): Promise<{ const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits( commits, effectiveIncludePaths, + includeMessages, ); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 9d457eb..f284ae1 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -34,19 +34,19 @@ describe("scanCommits", () => { ]; it("adds identifier when last action is re-add", () => { - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual(["BAC-39"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); it("adds identifier when only add commits are present", () => { - const result = scanCommits(commits.slice(0, 2), null); + const result = scanCommits(commits.slice(0, 2), null, null); expect(ids(result.issueReferences)).toEqual(["BAC-39"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); it("reverts identifier when add is followed by revert", () => { - const result = scanCommits(commits.slice(0, 4), null); + const result = scanCommits(commits.slice(0, 4), null, null); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["BAC-39"]); }); @@ -64,7 +64,7 @@ describe("scanCommits", () => { message: 'Revert "Fixes DRIVE-320: memory leak in background location service"', }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["DRIVE-320"]); }); @@ -74,7 +74,7 @@ describe("scanCommits", () => { { sha: "a1", message: "Bump v1-2 to v1-3" }, { sha: "r1", message: 'Revert "Bump v1-2 to v1-3"' }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); @@ -90,7 +90,7 @@ describe("scanCommits", () => { message: 'Revert "ENG-200: something"', }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-200"]); }); @@ -104,7 +104,7 @@ describe("scanCommits", () => { message: 'Revert "ENG-100: fix"', }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -118,7 +118,7 @@ describe("scanCommits", () => { }, { sha: "a1", branchName: "user/eng-100" }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); @@ -136,7 +136,7 @@ describe("scanCommits", () => { message: "Merge pull request #2\n\nFixes ENG-200", }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual(["ENG-200"]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -146,9 +146,58 @@ describe("scanCommits", () => { { sha: "a1", branchName: "user/eng-100", message: "Fixes ENG-100" }, { sha: "r1", message: 'Revert "Fixes ENG-100"' }, ]; - const result = scanCommits(commits, null); + const result = scanCommits(commits, null, null); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); }); + + describe("--include-messages filter", () => { + it("includes only commits whose subject matches the regex", () => { + const commits: CommitContext[] = [ + { sha: "c1", message: "feat: add login. Fixes ENG-100" }, + { sha: "c2", message: "chore: bump deps. Fixes ENG-200" }, + { sha: "c3", message: "fix: handle null. Fixes ENG-300" }, + ]; + const result = scanCommits(commits, null, "^(feat|fix):"); + expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-300"]); + expect(result.debugSink.inspectedShas).toEqual(["c1", "c3"]); + }); + + it("matches against the subject (first line) only, ignoring body", () => { + const commits: CommitContext[] = [{ sha: "c1", message: "chore: tidy\n\nfeat: ENG-100 add login (in body)" }]; + const result = scanCommits(commits, null, "^feat:"); + expect(ids(result.issueReferences)).toEqual([]); + expect(result.debugSink.inspectedShas).toEqual([]); + }); + + it("supports unanchored substring patterns", () => { + const commits: CommitContext[] = [ + { sha: "c1", message: "Squash: feat. Fixes ENG-100" }, + { sha: "c2", message: "chore: bump" }, + ]; + const result = scanCommits(commits, null, "feat"); + expect(ids(result.issueReferences)).toEqual(["ENG-100"]); + }); + + it("skips commits with no message when a regex is set", () => { + const commits: CommitContext[] = [ + { sha: "c1", branchName: "user/eng-100", message: null }, + { sha: "c2", branchName: "user/eng-200", message: "feat: add login" }, + ]; + const result = scanCommits(commits, null, "^feat:"); + expect(ids(result.issueReferences)).toEqual(["ENG-200"]); + expect(result.debugSink.inspectedShas).toEqual(["c2"]); + }); + + it("records the pattern on the debug sink", () => { + const result = scanCommits([{ sha: "c1", message: "feat: x" }], null, "^feat:"); + expect(result.debugSink.includeMessages).toBe("^feat:"); + }); + + it("leaves includeMessages null when filter is disabled", () => { + const result = scanCommits([{ sha: "c1", message: "anything" }], null, null); + expect(result.debugSink.includeMessages).toBeNull(); + }); + }); }); diff --git a/src/scan.ts b/src/scan.ts index 35a2fd1..1d06e92 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -14,12 +14,14 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t export function scanCommits( commits: CommitContext[], includePaths: string[] | null, + includeMessages: string | null, ): { issueReferences: IssueReference[]; revertedIssueReferences: IssueReference[]; prNumbers: number[]; debugSink: DebugSink; } { + const messageRegex = includeMessages ? new RegExp(includeMessages) : null; const lastAction = new Map(); const addedRefs = new Map(); const revertedRefs = new Map(); @@ -31,9 +33,18 @@ export function scanCommits( revertedIssues: {}, pullRequests: [], includePaths, + includeMessages, }; for (const commit of commits) { + if (messageRegex) { + const subject = (commit.message ?? "").split("\n", 1)[0]!; + if (!messageRegex.test(subject)) { + verbose(`Skipping commit ${commit.sha} — subject does not match --include-messages`); + continue; + } + } + debugSink.inspectedShas.push(commit.sha); for (const { identifier, source } of extractRevertedIssueIdentifiersForCommit(commit)) { diff --git a/src/types.ts b/src/types.ts index bd6ce6f..cbe7106 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,4 +107,5 @@ export type DebugSink = { revertedIssues: Record; // Issue identifier -> array of sources (reverted) pullRequests: PullRequestSource[]; // PR numbers found in commits includePaths: string[] | null; // Path filters applied during commit scanning + includeMessages: string | null; // Commit-message regex source applied during scanning }; From 6bdad6930155affa2bd2e7b37ad0b20fd4f37e6d Mon Sep 17 00:00:00 2001 From: autumn-n Date: Tue, 19 May 2026 22:11:17 +0900 Subject: [PATCH 2/5] Extract subject-parsing into a shared helper --- src/extractors.ts | 8 +++++++- src/scan.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/extractors.ts b/src/extractors.ts index d534cc0..ac3950d 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -182,7 +182,7 @@ function matchAllIdentifiers(text: string): IdentifierMatch[] { * convention itself signals intent. */ function matchCommonSubjectPatterns(message: string): IdentifierMatch[] { - const subject = message.split(/\r?\n/)[0] ?? ""; + const subject = getCommitSubject(message); const results: IdentifierMatch[] = []; for (const pattern of COMMON_SUBJECT_PATTERNS) { const match = subject.match(pattern); @@ -396,6 +396,12 @@ export function getRevertBranchDepth(branchName: string | null | undefined): num return parseRevertBranch(branchName).depth; } +export function getCommitSubject(message: string | null | undefined): string { + if (!message) return ""; + const newlineIdx = message.search(/\r?\n/); + return newlineIdx === -1 ? message : message.slice(0, newlineIdx); +} + /** * Unwrap `Revert "..."` layers on the subject line only. Scanning the whole * message would let a stray `"` in the body extend the capture past the real diff --git a/src/scan.ts b/src/scan.ts index 1d06e92..08a2e6c 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -2,6 +2,7 @@ import { extractLinearIssueIdentifiersForCommit, extractPullRequestNumbersForCommit, extractRevertedIssueIdentifiersForCommit, + getCommitSubject, } from "./extractors"; import { verbose } from "./log"; import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./types"; @@ -38,7 +39,7 @@ export function scanCommits( for (const commit of commits) { if (messageRegex) { - const subject = (commit.message ?? "").split("\n", 1)[0]!; + const subject = getCommitSubject(commit.message); if (!messageRegex.test(subject)) { verbose(`Skipping commit ${commit.sha} — subject does not match --include-messages`); continue; From c120953425f581a575f036a51c3f2d356e158b1d Mon Sep 17 00:00:00 2001 From: autumn-n Date: Tue, 19 May 2026 22:31:31 +0900 Subject: [PATCH 3/5] Apply --include-messages to inner subject of revert commits --- src/extractors.ts | 11 +++++++++++ src/scan.test.ts | 28 ++++++++++++++++++++++++++++ src/scan.ts | 4 ++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/extractors.ts b/src/extractors.ts index ac3950d..19d5bfc 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -402,6 +402,17 @@ export function getCommitSubject(message: string | null | undefined): string { return newlineIdx === -1 ? message : message.slice(0, newlineIdx); } +/** + * Returns the subject with any `Revert "..."` wrapping stripped. For a + * non-revert commit this is just the subject; for a revert it's the subject of + * the commit being reverted. Callers that want to match against what the + * change is *about* (not the revert mechanics) should use this. + */ +export function getEffectiveSubject(message: string | null | undefined): string { + if (!message) return ""; + return parseRevertMessage(message).inner; +} + /** * Unwrap `Revert "..."` layers on the subject line only. Scanning the whole * message would let a stray `"` in the body extend the capture past the real diff --git a/src/scan.test.ts b/src/scan.test.ts index f284ae1..92ab79b 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -199,5 +199,33 @@ describe("scanCommits", () => { const result = scanCommits([{ sha: "c1", message: "anything" }], null, null); expect(result.debugSink.includeMessages).toBeNull(); }); + + it("matches the inner subject of a revert so revert detection is not bypassed", () => { + const commits: CommitContext[] = [ + { sha: "a1", message: "fix: login bug. Fixes ENG-100" }, + { sha: "r1", message: 'Revert "fix: login bug. Fixes ENG-100"' }, + ]; + const result = scanCommits(commits, null, "^(feat|fix):"); + expect(ids(result.issueReferences)).toEqual([]); + expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); + }); + + it("matches the inner subject through nested revert wrappers", () => { + const commits: CommitContext[] = [ + { + sha: "ra1", + message: 'Revert "Revert "fix: login bug. Fixes ENG-100""', + }, + ]; + const result = scanCommits(commits, null, "^(feat|fix):"); + expect(ids(result.issueReferences)).toEqual(["ENG-100"]); + }); + + it("still skips commits whose inner subject does not match", () => { + const commits: CommitContext[] = [{ sha: "r1", message: 'Revert "chore: bump deps. Fixes ENG-200"' }]; + const result = scanCommits(commits, null, "^(feat|fix):"); + expect(ids(result.issueReferences)).toEqual([]); + expect(ids(result.revertedIssueReferences)).toEqual([]); + }); }); }); diff --git a/src/scan.ts b/src/scan.ts index 08a2e6c..8092d64 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -2,7 +2,7 @@ import { extractLinearIssueIdentifiersForCommit, extractPullRequestNumbersForCommit, extractRevertedIssueIdentifiersForCommit, - getCommitSubject, + getEffectiveSubject, } from "./extractors"; import { verbose } from "./log"; import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./types"; @@ -39,7 +39,7 @@ export function scanCommits( for (const commit of commits) { if (messageRegex) { - const subject = getCommitSubject(commit.message); + const subject = getEffectiveSubject(commit.message); if (!messageRegex.test(subject)) { verbose(`Skipping commit ${commit.sha} — subject does not match --include-messages`); continue; From aea8c0a9971a618348f893cbf20d0004ec2f058f Mon Sep 17 00:00:00 2001 From: autumn-n Date: Tue, 19 May 2026 22:47:51 +0900 Subject: [PATCH 4/5] Rename --include-messages to --include-subjects --- README.md | 12 ++++++------ src/args.test.ts | 20 ++++++++++---------- src/args.ts | 18 +++++++++--------- src/index.ts | 8 ++++---- src/scan.test.ts | 8 ++++---- src/scan.ts | 12 ++++++------ src/types.ts | 2 +- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 93d0175..fb8cf45 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ linear-release update --stage="in review" --name="Release 1.2.0" | `--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. | | `--stage` | `update` | Target deployment stage (required for `update`) | | `--include-paths` | `sync` | Filter commits by changed file paths | -| `--include-messages` | `sync` | Filter commits whose subject (first line) matches a regex | +| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | | `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | | `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | | `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | @@ -210,21 +210,21 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence. -### Commit Message Filtering +### Subject Filtering -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. +Use `--include-subjects` 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. ```bash # Only commits that mention a Linear issue identifier in the subject -linear-release sync --include-messages="[A-Z]{2,}-[0-9]+" +linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+" # Conventional Commits — keep user-impacting changes, drop chore/docs/test/ci -linear-release sync --include-messages="^(feat|fix|perf):" +linear-release sync --include-subjects="^(feat|fix|perf):" ``` 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. -`--include-messages` composes with `--include-paths`: a commit must pass both filters to be scanned. +`--include-subjects` composes with `--include-paths`: a commit must pass both filters to be scanned. ## How It Works diff --git a/src/args.test.ts b/src/args.test.ts index 923248a..4ee1052 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -81,23 +81,23 @@ describe("parseCLIArgs", () => { expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]); }); - it("defaults --include-messages to null", () => { + it("defaults --include-subjects to null", () => { const result = parseCLIArgs([]); - expect(result.includeMessages).toBeNull(); + expect(result.includeSubjects).toBeNull(); }); - it("returns --include-messages as the raw pattern string", () => { - const result = parseCLIArgs(["--include-messages", "^(feat|fix):"]); - expect(result.includeMessages).toBe("^(feat|fix):"); + it("returns --include-subjects as the raw pattern string", () => { + const result = parseCLIArgs(["--include-subjects", "^(feat|fix):"]); + expect(result.includeSubjects).toBe("^(feat|fix):"); }); - it("treats empty --include-messages as no filter", () => { - const result = parseCLIArgs(["--include-messages", ""]); - expect(result.includeMessages).toBeNull(); + it("treats empty --include-subjects as no filter", () => { + const result = parseCLIArgs(["--include-subjects", ""]); + expect(result.includeSubjects).toBeNull(); }); - it("throws a helpful error on invalid --include-messages regex", () => { - expect(() => parseCLIArgs(["--include-messages", "([unclosed"])).toThrow(/Invalid --include-messages regex/); + it("throws a helpful error on invalid --include-subjects regex", () => { + expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/); }); it("throws on unknown flags (strict mode)", () => { diff --git a/src/args.ts b/src/args.ts index 0be2f4d..5ceea9c 100644 --- a/src/args.ts +++ b/src/args.ts @@ -7,7 +7,7 @@ export type ParsedCLIArgs = { releaseVersion?: string; stageName?: string; includePaths: string[]; - includeMessages: string | null; + includeSubjects: string | null; jsonOutput: boolean; timeoutSeconds: number; logLevel: LogLevel; @@ -21,7 +21,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "release-version": { type: "string" }, stage: { type: "string" }, "include-paths": { type: "string" }, - "include-messages": { type: "string" }, + "include-subjects": { type: "string" }, json: { type: "boolean", default: false }, timeout: { type: "string" }, quiet: { type: "boolean", default: false }, @@ -49,16 +49,16 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { if (values.quiet) logLevel = LogLevel.Quiet; else if (values.verbose) logLevel = LogLevel.Verbose; - let includeMessages: string | null = null; - const rawIncludeMessages = values["include-messages"]; - if (rawIncludeMessages !== undefined && rawIncludeMessages.length > 0) { + let includeSubjects: string | null = null; + const rawIncludeSubjects = values["include-subjects"]; + if (rawIncludeSubjects !== undefined && rawIncludeSubjects.length > 0) { try { - new RegExp(rawIncludeMessages); + new RegExp(rawIncludeSubjects); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid --include-messages regex: ${detail}`); + throw new Error(`Invalid --include-subjects regex: ${detail}`); } - includeMessages = rawIncludeMessages; + includeSubjects = rawIncludeSubjects; } return { @@ -72,7 +72,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .map((p) => p.trim()) .filter((p) => p.length > 0) : [], - includeMessages, + includeSubjects, jsonOutput: values.json ?? false, timeoutSeconds, logLevel, diff --git a/src/index.ts b/src/index.ts index 138dec0..915bed3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,7 @@ Options: --release-version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) - --include-messages= Filter commits whose subject (first line) matches the regex + --include-subjects= Filter commits whose subject (first line) matches the regex --timeout= Abort if the operation exceeds this duration (default: 60) --json Output result as JSON (logs emitted as JSON Lines on stderr) --quiet Suppress info-level output (warnings and errors still printed) @@ -68,7 +68,7 @@ Examples: linear-release complete linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" - linear-release sync --include-messages="[A-Z]{2,}-[0-9]+" + linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+" `); process.exit(0); } @@ -94,7 +94,7 @@ const { releaseVersion, stageName, includePaths, - includeMessages, + includeSubjects, jsonOutput, timeoutSeconds, logLevel, @@ -224,7 +224,7 @@ async function syncCommand(): Promise<{ const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits( commits, effectiveIncludePaths, - includeMessages, + includeSubjects, ); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 92ab79b..3e71261 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -152,7 +152,7 @@ describe("scanCommits", () => { }); }); - describe("--include-messages filter", () => { + describe("--include-subjects filter", () => { it("includes only commits whose subject matches the regex", () => { const commits: CommitContext[] = [ { sha: "c1", message: "feat: add login. Fixes ENG-100" }, @@ -192,12 +192,12 @@ describe("scanCommits", () => { it("records the pattern on the debug sink", () => { const result = scanCommits([{ sha: "c1", message: "feat: x" }], null, "^feat:"); - expect(result.debugSink.includeMessages).toBe("^feat:"); + expect(result.debugSink.includeSubjects).toBe("^feat:"); }); - it("leaves includeMessages null when filter is disabled", () => { + it("leaves includeSubjects null when filter is disabled", () => { const result = scanCommits([{ sha: "c1", message: "anything" }], null, null); - expect(result.debugSink.includeMessages).toBeNull(); + expect(result.debugSink.includeSubjects).toBeNull(); }); it("matches the inner subject of a revert so revert detection is not bypassed", () => { diff --git a/src/scan.ts b/src/scan.ts index 8092d64..9f0f31b 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -15,14 +15,14 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t export function scanCommits( commits: CommitContext[], includePaths: string[] | null, - includeMessages: string | null, + includeSubjects: string | null, ): { issueReferences: IssueReference[]; revertedIssueReferences: IssueReference[]; prNumbers: number[]; debugSink: DebugSink; } { - const messageRegex = includeMessages ? new RegExp(includeMessages) : null; + const subjectRegex = includeSubjects ? new RegExp(includeSubjects) : null; const lastAction = new Map(); const addedRefs = new Map(); const revertedRefs = new Map(); @@ -34,14 +34,14 @@ export function scanCommits( revertedIssues: {}, pullRequests: [], includePaths, - includeMessages, + includeSubjects, }; for (const commit of commits) { - if (messageRegex) { + if (subjectRegex) { const subject = getEffectiveSubject(commit.message); - if (!messageRegex.test(subject)) { - verbose(`Skipping commit ${commit.sha} — subject does not match --include-messages`); + if (!subjectRegex.test(subject)) { + verbose(`Skipping commit ${commit.sha} — subject does not match --include-subjects`); continue; } } diff --git a/src/types.ts b/src/types.ts index cbe7106..12d0868 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,5 +107,5 @@ export type DebugSink = { revertedIssues: Record; // Issue identifier -> array of sources (reverted) pullRequests: PullRequestSource[]; // PR numbers found in commits includePaths: string[] | null; // Path filters applied during commit scanning - includeMessages: string | null; // Commit-message regex source applied during scanning + includeSubjects: string | null; // Subject regex source applied during scanning }; From e78cc780dbbf6eab6db49f69c03ca5bf43feca61 Mon Sep 17 00:00:00 2001 From: autumn-n Date: Tue, 19 May 2026 22:57:06 +0900 Subject: [PATCH 5/5] Group scanCommits filters into a ScanOptions object --- src/index.ts | 7 +++---- src/scan.test.ts | 38 +++++++++++++++++++------------------- src/scan.ts | 9 +++++++-- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/index.ts b/src/index.ts index 915bed3..a607d45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -221,11 +221,10 @@ async function syncCommand(): Promise<{ // git log returns newest-first; scanCommits needs chronological (oldest-first) for last-write-wins commits.reverse(); - const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits( - commits, - effectiveIncludePaths, + const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, { + includePaths: effectiveIncludePaths, includeSubjects, - ); + }); verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); diff --git a/src/scan.test.ts b/src/scan.test.ts index 3e71261..4e891de 100644 --- a/src/scan.test.ts +++ b/src/scan.test.ts @@ -34,19 +34,19 @@ describe("scanCommits", () => { ]; it("adds identifier when last action is re-add", () => { - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual(["BAC-39"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); it("adds identifier when only add commits are present", () => { - const result = scanCommits(commits.slice(0, 2), null, null); + const result = scanCommits(commits.slice(0, 2), {}); expect(ids(result.issueReferences)).toEqual(["BAC-39"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); it("reverts identifier when add is followed by revert", () => { - const result = scanCommits(commits.slice(0, 4), null, null); + const result = scanCommits(commits.slice(0, 4), {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["BAC-39"]); }); @@ -64,7 +64,7 @@ describe("scanCommits", () => { message: 'Revert "Fixes DRIVE-320: memory leak in background location service"', }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["DRIVE-320"]); }); @@ -74,7 +74,7 @@ describe("scanCommits", () => { { sha: "a1", message: "Bump v1-2 to v1-3" }, { sha: "r1", message: 'Revert "Bump v1-2 to v1-3"' }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); @@ -90,7 +90,7 @@ describe("scanCommits", () => { message: 'Revert "ENG-200: something"', }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-200"]); }); @@ -104,7 +104,7 @@ describe("scanCommits", () => { message: 'Revert "ENG-100: fix"', }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -118,7 +118,7 @@ describe("scanCommits", () => { }, { sha: "a1", branchName: "user/eng-100" }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); @@ -136,7 +136,7 @@ describe("scanCommits", () => { message: "Merge pull request #2\n\nFixes ENG-200", }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual(["ENG-200"]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -146,7 +146,7 @@ describe("scanCommits", () => { { sha: "a1", branchName: "user/eng-100", message: "Fixes ENG-100" }, { sha: "r1", message: 'Revert "Fixes ENG-100"' }, ]; - const result = scanCommits(commits, null, null); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -159,14 +159,14 @@ describe("scanCommits", () => { { sha: "c2", message: "chore: bump deps. Fixes ENG-200" }, { sha: "c3", message: "fix: handle null. Fixes ENG-300" }, ]; - const result = scanCommits(commits, null, "^(feat|fix):"); + const result = scanCommits(commits, { includeSubjects: "^(feat|fix):" }); expect(ids(result.issueReferences)).toEqual(["ENG-100", "ENG-300"]); expect(result.debugSink.inspectedShas).toEqual(["c1", "c3"]); }); it("matches against the subject (first line) only, ignoring body", () => { const commits: CommitContext[] = [{ sha: "c1", message: "chore: tidy\n\nfeat: ENG-100 add login (in body)" }]; - const result = scanCommits(commits, null, "^feat:"); + const result = scanCommits(commits, { includeSubjects: "^feat:" }); expect(ids(result.issueReferences)).toEqual([]); expect(result.debugSink.inspectedShas).toEqual([]); }); @@ -176,7 +176,7 @@ describe("scanCommits", () => { { sha: "c1", message: "Squash: feat. Fixes ENG-100" }, { sha: "c2", message: "chore: bump" }, ]; - const result = scanCommits(commits, null, "feat"); + const result = scanCommits(commits, { includeSubjects: "feat" }); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); }); @@ -185,18 +185,18 @@ describe("scanCommits", () => { { sha: "c1", branchName: "user/eng-100", message: null }, { sha: "c2", branchName: "user/eng-200", message: "feat: add login" }, ]; - const result = scanCommits(commits, null, "^feat:"); + const result = scanCommits(commits, { includeSubjects: "^feat:" }); expect(ids(result.issueReferences)).toEqual(["ENG-200"]); expect(result.debugSink.inspectedShas).toEqual(["c2"]); }); it("records the pattern on the debug sink", () => { - const result = scanCommits([{ sha: "c1", message: "feat: x" }], null, "^feat:"); + const result = scanCommits([{ sha: "c1", message: "feat: x" }], { includeSubjects: "^feat:" }); expect(result.debugSink.includeSubjects).toBe("^feat:"); }); it("leaves includeSubjects null when filter is disabled", () => { - const result = scanCommits([{ sha: "c1", message: "anything" }], null, null); + const result = scanCommits([{ sha: "c1", message: "anything" }], {}); expect(result.debugSink.includeSubjects).toBeNull(); }); @@ -205,7 +205,7 @@ describe("scanCommits", () => { { sha: "a1", message: "fix: login bug. Fixes ENG-100" }, { sha: "r1", message: 'Revert "fix: login bug. Fixes ENG-100"' }, ]; - const result = scanCommits(commits, null, "^(feat|fix):"); + const result = scanCommits(commits, { includeSubjects: "^(feat|fix):" }); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -217,13 +217,13 @@ describe("scanCommits", () => { message: 'Revert "Revert "fix: login bug. Fixes ENG-100""', }, ]; - const result = scanCommits(commits, null, "^(feat|fix):"); + const result = scanCommits(commits, { includeSubjects: "^(feat|fix):" }); expect(ids(result.issueReferences)).toEqual(["ENG-100"]); }); it("still skips commits whose inner subject does not match", () => { const commits: CommitContext[] = [{ sha: "r1", message: 'Revert "chore: bump deps. Fixes ENG-200"' }]; - const result = scanCommits(commits, null, "^(feat|fix):"); + const result = scanCommits(commits, { includeSubjects: "^(feat|fix):" }); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual([]); }); diff --git a/src/scan.ts b/src/scan.ts index 9f0f31b..0398428 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -7,6 +7,11 @@ import { import { verbose } from "./log"; import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./types"; +export type ScanOptions = { + includePaths?: string[] | null; + includeSubjects?: string | null; +}; + /** * Scan commits and produce added/reverted issue references using last-write-wins. * Expects commits in chronological order (oldest first). The caller must reverse @@ -14,14 +19,14 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t */ export function scanCommits( commits: CommitContext[], - includePaths: string[] | null, - includeSubjects: string | null, + options: ScanOptions = {}, ): { issueReferences: IssueReference[]; revertedIssueReferences: IssueReference[]; prNumbers: number[]; debugSink: DebugSink; } { + const { includePaths = null, includeSubjects = null } = options; const subjectRegex = includeSubjects ? new RegExp(includeSubjects) : null; const lastAction = new Map(); const addedRefs = new Map();