Skip to content

Commit 1ae492d

Browse files
authored
Allow passing --link to add links to a release (#92)
1 parent f4911c0 commit 1ae492d

4 files changed

Lines changed: 198 additions & 28 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
155155
| `--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. |
156156
| `--stage` | `update` | Target deployment stage (required for `update`) |
157157
| `--include-paths` | `sync` | Filter commits by changed file paths |
158+
| `--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. |
158159
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
159160
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
160161
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
@@ -210,13 +211,33 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g
210211

211212
Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence.
212213

214+
### Release Links
215+
216+
`--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard.
217+
218+
```bash
219+
# Bare URL — Linear derives the label ("GitHub" here)
220+
linear-release sync --link "https://github.com/acme/app/releases/tag/v1.2.0"
221+
222+
# Multiple labeled links
223+
linear-release sync \
224+
--link "CI run=https://ci.example.com/run/123" \
225+
--link "Deploy dashboard=https://deploys.example.com/v1.2.0"
226+
227+
# Works on complete and update too
228+
linear-release complete --release-version="1.2.0" \
229+
--link "https://github.com/acme/app/releases/tag/v1.2.0"
230+
```
231+
232+
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:`.
233+
213234
## How It Works
214235

215236
1. **Fetches the latest release** from your Linear pipeline to determine the commit range
216237
2. **Scans commits** between the commit from the last release and the current commit
217238
3. **Extracts issue identifiers** from branch names and commit messages (e.g., `feat/ENG-123-add-feature`)
218239
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)
219-
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.
240+
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.
220241

221242
> [!NOTE]
222243
> **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).

src/args.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,70 @@ describe("parseCLIArgs", () => {
9191
expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]);
9292
});
9393

94+
it("parses repeatable --link values", () => {
95+
const result = parseCLIArgs([
96+
"sync",
97+
"--link",
98+
"Pipeline=https://ci.example.com/run/123?attempt=1",
99+
"--link=GitHub release=https://github.com/acme/app/releases/tag/v1.2.0",
100+
]);
101+
102+
expect(result.links).toEqual([
103+
{ label: "Pipeline", url: "https://ci.example.com/run/123?attempt=1" },
104+
{ label: "GitHub release", url: "https://github.com/acme/app/releases/tag/v1.2.0" },
105+
]);
106+
});
107+
108+
it("parses --link with a bare URL and derives the label", () => {
109+
const result = parseCLIArgs(["sync", "--link", "https://github.com/acme/app/actions/runs/123"]);
110+
111+
expect(result.links).toEqual([{ url: "https://github.com/acme/app/actions/runs/123" }]);
112+
});
113+
114+
it("parses --link with a bare URL containing equals signs", () => {
115+
const result = parseCLIArgs(["sync", "--link", "https://ci.example.com/run?id=123&attempt=1"]);
116+
117+
expect(result.links).toEqual([{ url: "https://ci.example.com/run?id=123&attempt=1" }]);
118+
});
119+
120+
it("trims --link labels and URLs", () => {
121+
const result = parseCLIArgs(["sync", "--link", " Pipeline = https://ci.example.com/run/123 "]);
122+
123+
expect(result.links).toEqual([{ label: "Pipeline", url: "https://ci.example.com/run/123" }]);
124+
});
125+
126+
it("throws on --link with neither URL nor label separator", () => {
127+
expect(() => parseCLIArgs(["sync", "--link", "not-a-url"])).toThrow('Invalid --link value: "not-a-url"');
128+
});
129+
130+
it("throws on --link with empty label", () => {
131+
expect(() => parseCLIArgs(["sync", "--link", "=https://ci.example.com/run/123"])).toThrow(
132+
"Link label must not be empty",
133+
);
134+
});
135+
136+
it("throws on --link with empty URL", () => {
137+
expect(() => parseCLIArgs(["sync", "--link", "Pipeline="])).toThrow("Link URL must not be empty");
138+
});
139+
140+
it("accepts non-http URL schemes and defers protocol validation to the server", () => {
141+
const result = parseCLIArgs(["sync", "--link", "Pipeline=ftp://ci.example.com/run/123"]);
142+
143+
expect(result.links).toEqual([{ label: "Pipeline", url: "ftp://ci.example.com/run/123" }]);
144+
});
145+
146+
it("parses --link with complete", () => {
147+
const result = parseCLIArgs(["complete", "--link", "Pipeline=https://ci.example.com/run/123"]);
148+
149+
expect(result.links).toEqual([{ label: "Pipeline", url: "https://ci.example.com/run/123" }]);
150+
});
151+
152+
it("parses --link with update", () => {
153+
const result = parseCLIArgs(["update", "--stage", "production", "--link", "https://ci.example.com/run/123"]);
154+
155+
expect(result.links).toEqual([{ url: "https://ci.example.com/run/123" }]);
156+
});
157+
94158
it("throws on unknown flags (strict mode)", () => {
95159
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
96160
});

src/args.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,59 @@
11
import { parseArgs } from "node:util";
22
import { LogLevel } from "./log";
33

4+
export type ReleaseLink = {
5+
label?: string;
6+
url: string;
7+
};
8+
49
export type ParsedCLIArgs = {
510
command: string;
611
releaseName?: string;
712
releaseVersion?: string;
813
stageName?: string;
914
baseRef?: string;
1015
includePaths: string[];
16+
links: ReleaseLink[];
1117
jsonOutput: boolean;
1218
timeoutSeconds: number;
1319
logLevel: LogLevel;
1420
};
1521

22+
function parseReleaseLink(value: string): ReleaseLink {
23+
const bareUrl = parseAbsoluteUrl(value.trim());
24+
if (bareUrl) {
25+
return { url: bareUrl.href };
26+
}
27+
28+
const separatorIndex = value.indexOf("=");
29+
if (separatorIndex === -1) {
30+
throw new Error(`Invalid --link value: "${value}". Expected "https://example.com" or "Label=https://example.com".`);
31+
}
32+
const label = value.slice(0, separatorIndex).trim();
33+
const url = value.slice(separatorIndex + 1).trim();
34+
if (!label) {
35+
throw new Error(`Invalid --link value: "${value}". Link label must not be empty.`);
36+
}
37+
if (!url) {
38+
throw new Error(`Invalid --link value: "${value}". Link URL must not be empty.`);
39+
}
40+
41+
const parsedUrl = parseAbsoluteUrl(url);
42+
if (!parsedUrl) {
43+
throw new Error(`Invalid --link URL: "${url}". Expected an absolute URL with a scheme (e.g. https://example.com).`);
44+
}
45+
46+
return { label, url: parsedUrl.href };
47+
}
48+
49+
function parseAbsoluteUrl(value: string): URL | undefined {
50+
try {
51+
return new URL(value);
52+
} catch {
53+
return undefined;
54+
}
55+
}
56+
1657
export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
1758
const { values, positionals } = parseArgs({
1859
args: argv,
@@ -22,6 +63,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
2263
stage: { type: "string" },
2364
"base-ref": { type: "string" },
2465
"include-paths": { type: "string" },
66+
link: { type: "string", multiple: true },
2567
json: { type: "boolean", default: false },
2668
timeout: { type: "string" },
2769
quiet: { type: "boolean", default: false },
@@ -49,8 +91,11 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
4991
if (values.quiet) logLevel = LogLevel.Quiet;
5092
else if (values.verbose) logLevel = LogLevel.Verbose;
5193

94+
const command = positionals[0] || "sync";
95+
const links = (values.link ?? []).map(parseReleaseLink);
96+
5297
return {
53-
command: positionals[0] || "sync",
98+
command,
5499
releaseName: values.name,
55100
releaseVersion: values["release-version"],
56101
stageName: values.stage,
@@ -61,6 +106,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
61106
.map((p) => p.trim())
62107
.filter((p) => p.length > 0)
63108
: [],
109+
links,
64110
jsonOutput: values.json ?? false,
65111
timeoutSeconds,
66112
logLevel,

0 commit comments

Comments
 (0)