diff --git a/README.md b/README.md index a8eb2e7..945e59f 100644 --- a/README.md +++ b/README.md @@ -155,6 +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-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | | `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | | `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | | `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | @@ -215,6 +216,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. + ### Release Links `--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard. diff --git a/src/args.test.ts b/src/args.test.ts index 094c192..cb1a49d 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -91,6 +91,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("parses repeatable --link values", () => { const result = parseCLIArgs([ "sync", diff --git a/src/args.ts b/src/args.ts index 1121931..b2782d1 100644 --- a/src/args.ts +++ b/src/args.ts @@ -26,6 +26,7 @@ export type ParsedCLIArgs = { stageName?: string; baseRef?: string; includePaths: string[]; + includeSubjects: string | null; links: ReleaseLink[]; documents: ReleaseDocumentSpec[]; releaseNotes?: ReleaseNoteSpec; @@ -130,6 +131,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { stage: { type: "string" }, "base-ref": { type: "string" }, "include-paths": { type: "string" }, + "include-subjects": { type: "string" }, link: { type: "string", multiple: true }, document: { type: "string", multiple: true }, "document-file": { type: "string", multiple: true }, @@ -163,6 +165,17 @@ 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; + } const command = positionals[0] || "sync"; const links = (values.link ?? []).map(parseReleaseLink); @@ -210,6 +223,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .map((p) => p.trim()) .filter((p) => p.length > 0) : [], + includeSubjects, links, documents, releaseNotes, @@ -218,3 +232,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { logLevel, }; } + +export function getCLIWarnings(_args: ParsedCLIArgs): string[] { + return []; +} 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 700a95d..d295944 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,14 @@ import { IssueReference, RepoInfo, } from "./types"; -import { parseCLIArgs, ReleaseContentSource, ReleaseDocumentSpec, ReleaseLink, ReleaseNoteSpec } from "./args"; +import { + getCLIWarnings, + parseCLIArgs, + ReleaseContentSource, + ReleaseDocumentSpec, + ReleaseLink, + ReleaseNoteSpec, +} from "./args"; import { error, info, setJsonMode, setLogLevel, setStderr, verbose, warn } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; @@ -51,6 +58,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 --link Add a link to the targeted release (repeatable) --document Attach a document to the release (repeatable, Title required) --document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable) @@ -73,6 +81,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]+" linear-release sync --link "https://ci.example.com/run/123" linear-release sync --link "Pipeline=https://ci.example.com/run/123" linear-release sync --document-file "Changelog=./CHANGELOG.md" @@ -105,6 +114,7 @@ const { stageName, baseRef, includePaths, + includeSubjects, links, documents: documentSpecs, releaseNotes: releaseNotesSpec, @@ -112,6 +122,7 @@ const { timeoutSeconds, logLevel, } = parsedArgs; +const cliWarnings = getCLIWarnings(parsedArgs); type ReleaseDocument = { title: string; content: string }; type ReleaseNotes = { content: string; title?: string }; @@ -206,6 +217,9 @@ const logEnvironmentSummary = () => { if (releaseVersion) { info(`Using custom release version: ${releaseVersion}`); } + for (const warningMessage of cliWarnings) { + warn(warningMessage); + } }; const getDevApiUrl = () => { @@ -331,10 +345,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 };