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
51 changes: 40 additions & 11 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 |
| `--issue-id-pattern` | `sync` | Regex with one capture group around the issue ID, applied per line of the commit message. Anchor with `^` to match once per line; omit `^` to match every occurrence per line. Detects IDs from conventions like `[LIN-123] My title` that don't use magic words. |
| `--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,11 +210,39 @@ 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.

### Issue Detection

By default, issue IDs are picked up from branch names (e.g. `feat/ENG-123-add-feature`) and from commit messages preceded by a "magic word" like `Fixes ENG-123` or `Closes ENG-123`. Use `--issue-id-pattern` to add a regex-driven detection path that fits your team's commit convention.

**`--issue-id-pattern=<regex>`** detects issue IDs from a regex applied to each line of the commit message. The first capture group must wrap the ID, and every occurrence on a line is matched. Useful for PR-title conventions that don't include a magic word.

```bash
# Bracketed prefix anchored to line start: [LIN-123] My PR title
linear-release sync --issue-id-pattern='^\[(.+?)\]'

# Unanchored: catches multi-bracket titles like [LIN-1] [LIN-2] thing
linear-release sync --issue-id-pattern='\[(.+?)\]'

# Parenthesised prefix: (LIN-1): My PR title
linear-release sync --issue-id-pattern='^\((.+?)\):'

# Colon-suffixed line-start ID: LIN-1: My PR title
linear-release sync --issue-id-pattern='^(\w{1,7}-[0-9]{1,9}):'
```

Anchored patterns (with `^`) match at most once per line; unanchored patterns find every occurrence. The pattern composes with the default magic-word detection — both run, and their results are unioned. Brackets in body prose like Markdown link references `[Release notes]` capture but contribute nothing because their contents don't match the `<KEY>-<NUMBER>` shape.

#### Flags

Pass the regex source only, e.g. `'^\[(.+?)\]'`. Do not wrap it in `/.../` delimiters; the CLI will reject that form. Flags such as `i` or `m` cannot be set on the pattern.

Case-sensitivity rarely matters anyway: ID matching is already case-insensitive, so `[lin-123]` and `[LIN-123]` both resolve to `LIN-123`.

## How It Works

1. **Fetches the latest release** from your Linear pipeline to determine the commit range
2. **Scans commits** between the commit from the last release and the current commit
3. **Extracts issue identifiers** from branch names and commit messages (e.g., `feat/ENG-123-add-feature`)
3. **Extracts issue identifiers** from branch names and commit messages: by default via magic words (`Fixes LIN-1`); see [Issue Detection](#issue-detection) for prefix conventions
4. **Detects pull/merge request numbers** from commit messages — GitHub `Title (#42)` / `Merge pull request #42`, and GitLab `See merge request <group>/<project>!42` trailers (emitted whenever a merge commit is created)
5. **Syncs data to Linear** that adds issues to a newly created completed release (continuous pipelines) or the currently in-progress release (scheduled pipelines)

Expand Down
36 changes: 36 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,40 @@ describe("parseCLIArgs", () => {
it("throws when --quiet and --verbose are both passed", () => {
expect(() => parseCLIArgs(["--quiet", "--verbose"])).toThrow("Conflicting log level flags");
});

it("defaults --issue-id-pattern to undefined", () => {
const result = parseCLIArgs([]);
expect(result.issueIdPattern).toBeUndefined();
});

it("parses --issue-id-pattern into a RegExp", () => {
const result = parseCLIArgs(["--issue-id-pattern=^\\[(.+?)\\]"]);
expect(result.issueIdPattern).toBeInstanceOf(RegExp);
expect(result.issueIdPattern!.test("[LIN-1] foo")).toBe(true);
});

it("throws on invalid --issue-id-pattern regex", () => {
expect(() => parseCLIArgs(["--issue-id-pattern=["])).toThrow("Invalid --issue-id-pattern");
});

it("throws on --issue-id-pattern with no capture group", () => {
expect(() => parseCLIArgs(["--issue-id-pattern=^\\[.+?\\]"])).toThrow("exactly one capture group");
});

it("throws on --issue-id-pattern with multiple capture groups", () => {
expect(() => parseCLIArgs(["--issue-id-pattern=^(\\[)(.+?)\\]"])).toThrow("exactly one capture group");
});

it("treats --issue-id-pattern='' as absent", () => {
const result = parseCLIArgs(["--issue-id-pattern="]);
expect(result.issueIdPattern).toBeUndefined();
});

it("rejects --issue-id-pattern passed as /source/flags literal", () => {
expect(() => parseCLIArgs(["--issue-id-pattern=/^\\[(.+?)\\]/i"])).toThrow("pass the pattern source directly");
});

it("rejects --issue-id-pattern passed as /source/ literal with no flags", () => {
expect(() => parseCLIArgs(["--issue-id-pattern=/^\\[(.+?)\\]/"])).toThrow("pass the pattern source directly");
});
});
40 changes: 40 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ParsedCLIArgs = {
jsonOutput: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
issueIdPattern?: RegExp;
};

export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
Expand All @@ -24,6 +25,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
timeout: { type: "string" },
quiet: { type: "boolean", default: false },
verbose: { type: "boolean", default: false },
"issue-id-pattern": { type: "string" },
},
allowPositionals: true,
strict: true,
Expand All @@ -47,6 +49,32 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
if (values.quiet) logLevel = LogLevel.Quiet;
else if (values.verbose) logLevel = LogLevel.Verbose;

let issueIdPattern: RegExp | undefined;
const rawPattern = values["issue-id-pattern"];
if (rawPattern !== undefined && rawPattern.length > 0) {
// Reject `/source/flags` literal-regex syntax: `new RegExp("/foo/i")` would
// silently match the text `/foo/i`, not what the user meant. The value is
// always treated as the pattern source; flags can't be configured.
if (/^\/.+\/[gimsuy]*$/.test(rawPattern)) {
throw new Error(
`Invalid --issue-id-pattern: pass the pattern source directly (e.g. '^\\[(.+?)\\]'), ` +
`not as a /.../flags literal. Flags inside the value are not supported.`,
);
}
try {
issueIdPattern = new RegExp(rawPattern);
} catch (error) {
throw new Error(`Invalid --issue-id-pattern: ${(error as Error).message}`);
}
const groupCount = countCaptureGroups(issueIdPattern);
if (groupCount !== 1) {
throw new Error(
`Invalid --issue-id-pattern: expected exactly one capture group, found ${groupCount}. ` +
`Example: '^\\[(.+?)\\]' to detect '[LIN-123] My title'.`,
);
}
}

return {
command: positionals[0] || "sync",
releaseName: values.name,
Expand All @@ -61,9 +89,21 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
jsonOutput: values.json ?? false,
timeoutSeconds,
logLevel,
issueIdPattern,
};
}

/**
* Count the capture groups in a regex by appending an empty alternative — the
* resulting match against "" returns `groupCount + 1` elements.
*/
function countCaptureGroups(re: RegExp): number {
// Append `|` so the empty string always matches; `exec` then returns one
// entry per capture group plus the full match.
const probe = new RegExp(`${re.source}|`);
return probe.exec("")!.length - 1;
}

export function getCLIWarnings(_args: ParsedCLIArgs): string[] {
return [];
}
Loading
Loading