diff --git a/README.md b/README.md index b5fe209..bc4fda4 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 fb5ac45..9ee60e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,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) @@ -68,6 +69,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); } @@ -87,8 +89,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) { @@ -214,6 +225,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 };