Skip to content
Open
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
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
15 changes: 15 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type ParsedCLIArgs = {
releaseVersion?: string;
stageName?: string;
includePaths: string[];
includeMessages: string | null;
jsonOutput: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
Expand All @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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-messages=<regex> Filter commits whose subject (first line) matches the regex
--timeout=<seconds> 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)
Expand All @@ -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);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)}`);
Expand Down
69 changes: 59 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, 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"]);
});
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, null, null);
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, null, null);
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, null, null);
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, null, null);
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, null, null);
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, null, null);
expect(ids(result.issueReferences)).toEqual(["ENG-200"]);
expect(ids(result.revertedIssueReferences)).toEqual(["ENG-100"]);
});
Expand All @@ -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();
});
});
});
11 changes: 11 additions & 0 deletions src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, "added" | "reverted">();
const addedRefs = new Map<string, IssueReference>();
const revertedRefs = new Map<string, IssueReference>();
Expand All @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,5 @@ export type DebugSink = {
revertedIssues: Record<string, IssueSource[]>; // 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
};
Loading