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
23 changes: 22 additions & 1 deletion 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 |
| `--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. |
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
| `--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. |
Expand Down Expand Up @@ -210,13 +211,33 @@ 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.

### Release Links

`--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard.

```bash
# Bare URL — Linear derives the label ("GitHub" here)
linear-release sync --link "https://github.com/acme/app/releases/tag/v1.2.0"

# Multiple labeled links
linear-release sync \
--link "CI run=https://ci.example.com/run/123" \
--link "Deploy dashboard=https://deploys.example.com/v1.2.0"

# Works on complete and update too
linear-release complete --release-version="1.2.0" \
--link "https://github.com/acme/app/releases/tag/v1.2.0"
```

Each value is either an absolute URL or `Label=URL`. Both `--link "Label=..."` and `--link="Label=..."` are accepted. `http(s)` is the typical scheme; the server rejects unsafe ones like `javascript:` or `data:`.

## 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`)
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). PR/MR numbers are sent alongside the repository info, and Linear resolves them back to any issues linked to those PRs/MRs — so issues attached only via a PR/MR (not mentioned in a commit message or branch name) are still picked up.
5. **Syncs data to Linear** that adds issues and provided links to a newly created completed release (continuous pipelines) or the currently in-progress release (scheduled pipelines). PR/MR numbers are sent alongside the repository info, and Linear resolves them back to any issues linked to those PRs/MRs — so issues attached only via a PR/MR (not mentioned in a commit message or branch name) are still picked up.

> [!NOTE]
> **First sync**: when no prior release exists for the pipeline, only the current commit is scanned (there's no previous SHA to bound the range from).
Expand Down
64 changes: 64 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,70 @@ describe("parseCLIArgs", () => {
expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]);
});

it("parses repeatable --link values", () => {
const result = parseCLIArgs([
"sync",
"--link",
"Pipeline=https://ci.example.com/run/123?attempt=1",
"--link=GitHub release=https://github.com/acme/app/releases/tag/v1.2.0",
]);

expect(result.links).toEqual([
{ label: "Pipeline", url: "https://ci.example.com/run/123?attempt=1" },
{ label: "GitHub release", url: "https://github.com/acme/app/releases/tag/v1.2.0" },
]);
});

it("parses --link with a bare URL and derives the label", () => {
const result = parseCLIArgs(["sync", "--link", "https://github.com/acme/app/actions/runs/123"]);

expect(result.links).toEqual([{ url: "https://github.com/acme/app/actions/runs/123" }]);
});

it("parses --link with a bare URL containing equals signs", () => {
const result = parseCLIArgs(["sync", "--link", "https://ci.example.com/run?id=123&attempt=1"]);

expect(result.links).toEqual([{ url: "https://ci.example.com/run?id=123&attempt=1" }]);
});

it("trims --link labels and URLs", () => {
const result = parseCLIArgs(["sync", "--link", " Pipeline = https://ci.example.com/run/123 "]);

expect(result.links).toEqual([{ label: "Pipeline", url: "https://ci.example.com/run/123" }]);
});

it("throws on --link with neither URL nor label separator", () => {
expect(() => parseCLIArgs(["sync", "--link", "not-a-url"])).toThrow('Invalid --link value: "not-a-url"');
});

it("throws on --link with empty label", () => {
expect(() => parseCLIArgs(["sync", "--link", "=https://ci.example.com/run/123"])).toThrow(
"Link label must not be empty",
);
});

it("throws on --link with empty URL", () => {
expect(() => parseCLIArgs(["sync", "--link", "Pipeline="])).toThrow("Link URL must not be empty");
});

it("accepts non-http URL schemes and defers protocol validation to the server", () => {
const result = parseCLIArgs(["sync", "--link", "Pipeline=ftp://ci.example.com/run/123"]);

expect(result.links).toEqual([{ label: "Pipeline", url: "ftp://ci.example.com/run/123" }]);
});

it("parses --link with complete", () => {
const result = parseCLIArgs(["complete", "--link", "Pipeline=https://ci.example.com/run/123"]);

expect(result.links).toEqual([{ label: "Pipeline", url: "https://ci.example.com/run/123" }]);
});

it("parses --link with update", () => {
const result = parseCLIArgs(["update", "--stage", "production", "--link", "https://ci.example.com/run/123"]);

expect(result.links).toEqual([{ url: "https://ci.example.com/run/123" }]);
});

it("throws on unknown flags (strict mode)", () => {
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
});
Expand Down
48 changes: 47 additions & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,59 @@
import { parseArgs } from "node:util";
import { LogLevel } from "./log";

export type ReleaseLink = {
label?: string;
url: string;
};

export type ParsedCLIArgs = {
command: string;
releaseName?: string;
releaseVersion?: string;
stageName?: string;
baseRef?: string;
includePaths: string[];
links: ReleaseLink[];
jsonOutput: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
};

function parseReleaseLink(value: string): ReleaseLink {
const bareUrl = parseAbsoluteUrl(value.trim());
if (bareUrl) {
return { url: bareUrl.href };
}

const separatorIndex = value.indexOf("=");
if (separatorIndex === -1) {
throw new Error(`Invalid --link value: "${value}". Expected "https://example.com" or "Label=https://example.com".`);
}
const label = value.slice(0, separatorIndex).trim();
const url = value.slice(separatorIndex + 1).trim();
if (!label) {
throw new Error(`Invalid --link value: "${value}". Link label must not be empty.`);
}
if (!url) {
throw new Error(`Invalid --link value: "${value}". Link URL must not be empty.`);
}

const parsedUrl = parseAbsoluteUrl(url);
if (!parsedUrl) {
throw new Error(`Invalid --link URL: "${url}". Expected an absolute URL with a scheme (e.g. https://example.com).`);
}

return { label, url: parsedUrl.href };
}

function parseAbsoluteUrl(value: string): URL | undefined {
try {
return new URL(value);
} catch {
return undefined;
}
}

export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
const { values, positionals } = parseArgs({
args: argv,
Expand All @@ -22,6 +63,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
stage: { type: "string" },
"base-ref": { type: "string" },
"include-paths": { type: "string" },
link: { type: "string", multiple: true },
json: { type: "boolean", default: false },
timeout: { type: "string" },
quiet: { type: "boolean", default: false },
Expand Down Expand Up @@ -49,8 +91,11 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
if (values.quiet) logLevel = LogLevel.Quiet;
else if (values.verbose) logLevel = LogLevel.Verbose;

const command = positionals[0] || "sync";
const links = (values.link ?? []).map(parseReleaseLink);

return {
command: positionals[0] || "sync",
command,
releaseName: values.name,
releaseVersion: values["release-version"],
stageName: values.stage,
Expand All @@ -61,6 +106,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
.map((p) => p.trim())
.filter((p) => p.length > 0)
: [],
links,
jsonOutput: values.json ?? false,
timeoutSeconds,
logLevel,
Expand Down
Loading
Loading