diff --git a/README.md b/README.md index 24a580a..fb8cf45 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-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 | +| `--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. +### Subject Filtering + +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-subjects="[A-Z]{2,}-[0-9]+" + +# Conventional Commits — keep user-impacting changes, drop chore/docs/test/ci +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-subjects` 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..4ee1052 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-subjects to null", () => { + const result = parseCLIArgs([]); + expect(result.includeSubjects).toBeNull(); + }); + + 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-subjects as no filter", () => { + const result = parseCLIArgs(["--include-subjects", ""]); + expect(result.includeSubjects).toBeNull(); + }); + + 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)", () => { expect(() => parseCLIArgs(["--unknown-flag"])).toThrow(); }); diff --git a/src/args.ts b/src/args.ts index 86ff11a..5ceea9c 100644 --- a/src/args.ts +++ b/src/args.ts @@ -7,6 +7,7 @@ export type ParsedCLIArgs = { releaseVersion?: string; stageName?: string; includePaths: string[]; + includeSubjects: 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-subjects": { 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 includeSubjects: string | null = null; + const rawIncludeSubjects = values["include-subjects"]; + if (rawIncludeSubjects !== undefined && rawIncludeSubjects.length > 0) { + try { + new RegExp(rawIncludeSubjects); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid --include-subjects regex: ${detail}`); + } + includeSubjects = rawIncludeSubjects; + } + 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) : [], + includeSubjects, jsonOutput: values.json ?? false, timeoutSeconds, logLevel, diff --git a/src/extractors.ts b/src/extractors.ts index d534cc0..19d5bfc 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,23 @@ 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); +} + +/** + * 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/index.ts b/src/index.ts index 3f5b07e..a607d45 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-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) @@ -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-subjects="[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, + includeSubjects, + jsonOutput, + timeoutSeconds, + logLevel, +} = parsedArgs; const cliWarnings = getCLIWarnings(parsedArgs); setLogLevel(logLevel); if (jsonOutput) { @@ -210,10 +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 9d457eb..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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + const result = scanCommits(commits, {}); expect(ids(result.issueReferences)).toEqual(["ENG-200"]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); @@ -146,9 +146,86 @@ 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, {}); expect(ids(result.issueReferences)).toEqual([]); expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]); }); }); + + 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" }, + { sha: "c2", message: "chore: bump deps. Fixes ENG-200" }, + { sha: "c3", message: "fix: handle null. Fixes ENG-300" }, + ]; + 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, { includeSubjects: "^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, { includeSubjects: "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, { 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" }], { includeSubjects: "^feat:" }); + expect(result.debugSink.includeSubjects).toBe("^feat:"); + }); + + it("leaves includeSubjects null when filter is disabled", () => { + const result = scanCommits([{ sha: "c1", message: "anything" }], {}); + expect(result.debugSink.includeSubjects).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, { includeSubjects: "^(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, { 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, { includeSubjects: "^(feat|fix):" }); + expect(ids(result.issueReferences)).toEqual([]); + expect(ids(result.revertedIssueReferences)).toEqual([]); + }); + }); }); diff --git a/src/scan.ts b/src/scan.ts index 35a2fd1..0398428 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -2,10 +2,16 @@ import { extractLinearIssueIdentifiersForCommit, extractPullRequestNumbersForCommit, extractRevertedIssueIdentifiersForCommit, + getEffectiveSubject, } from "./extractors"; 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 @@ -13,13 +19,15 @@ import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./t */ export function scanCommits( commits: CommitContext[], - includePaths: 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(); const revertedRefs = new Map(); @@ -31,9 +39,18 @@ export function scanCommits( revertedIssues: {}, pullRequests: [], includePaths, + includeSubjects, }; for (const commit of commits) { + if (subjectRegex) { + const subject = getEffectiveSubject(commit.message); + if (!subjectRegex.test(subject)) { + verbose(`Skipping commit ${commit.sha} — subject does not match --include-subjects`); + 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..12d0868 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 + includeSubjects: string | null; // Subject regex source applied during scanning };