Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type ParsedCLIArgs = {
stageName?: string;
baseRef?: string;
includePaths: string[];
includeSubjects: string | null;
links: ReleaseLink[];
documents: ReleaseDocumentSpec[];
releaseNotes?: ReleaseNoteSpec;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -210,6 +223,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
.map((p) => p.trim())
.filter((p) => p.length > 0)
: [],
includeSubjects,
links,
documents,
releaseNotes,
Expand All @@ -218,3 +232,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
logLevel,
};
}

export function getCLIWarnings(_args: ParsedCLIArgs): string[] {
return [];
}
19 changes: 18 additions & 1 deletion src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,6 +58,7 @@ Options:
--release-version=<version> Release version identifier
--stage=<stage> Deployment stage (required for update)
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
--include-subjects=<regex> Filter commits whose subject (first line) matches the regex
--link <URL|Label=URL> Add a link to the targeted release (repeatable)
--document <Title=content> 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)
Expand All @@ -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"
Expand Down Expand Up @@ -105,13 +114,15 @@ const {
stageName,
baseRef,
includePaths,
includeSubjects,
links,
documents: documentSpecs,
releaseNotes: releaseNotesSpec,
jsonOutput,
timeoutSeconds,
logLevel,
} = parsedArgs;
const cliWarnings = getCLIWarnings(parsedArgs);

type ReleaseDocument = { title: string; content: string };
type ReleaseNotes = { content: string; title?: string };
Expand Down Expand Up @@ -206,6 +217,9 @@ const logEnvironmentSummary = () => {
if (releaseVersion) {
info(`Using custom release version: ${releaseVersion}`);
}
for (const warningMessage of cliWarnings) {
warn(warningMessage);
}
};

const getDevApiUrl = () => {
Expand Down Expand Up @@ -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)}`);

Expand Down
97 changes: 87 additions & 10 deletions src/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
Expand All @@ -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"]);
});
Expand All @@ -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([]);
});
Expand All @@ -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"]);
});
Expand All @@ -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"]);
});
Expand All @@ -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([]);
});
Expand All @@ -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"]);
});
Expand All @@ -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([]);
});
});
});
Loading
Loading