diff --git a/README.md b/README.md index 8a8c3f7..552728e 100644 --- a/README.md +++ b/README.md @@ -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 `..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. | @@ -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 /!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). diff --git a/src/args.test.ts b/src/args.test.ts index 219f8a6..996ebb6 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -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(); }); diff --git a/src/args.ts b/src/args.ts index b899839..cc91adc 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,6 +1,11 @@ import { parseArgs } from "node:util"; import { LogLevel } from "./log"; +export type ReleaseLink = { + label?: string; + url: string; +}; + export type ParsedCLIArgs = { command: string; releaseName?: string; @@ -8,11 +13,47 @@ export type ParsedCLIArgs = { 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, @@ -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 }, @@ -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, @@ -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, diff --git a/src/index.ts b/src/index.ts index ec8d269..0598b74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { IssueReference, RepoInfo, } from "./types"; -import { getCLIWarnings, parseCLIArgs } from "./args"; +import { getCLIWarnings, parseCLIArgs, ReleaseLink } from "./args"; import { error, info, setJsonMode, setLogLevel, setStderr, verbose, warn } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; @@ -51,6 +51,7 @@ Options: --release-version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) + --link Add a link to the targeted release (repeatable) --base-ref= Override sync scan base (exclusive; scans ..HEAD) --timeout= Abort if the operation exceeds this duration (default: 60) --json Output result as JSON (logs emitted as JSON Lines on stderr) @@ -68,6 +69,8 @@ Examples: linear-release complete linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" + linear-release sync --link "https://ci.example.com/run/123" + linear-release sync --link "Pipeline=https://ci.example.com/run/123" linear-release sync --base-ref= --include-paths="apps/web/**" `); process.exit(0); @@ -88,8 +91,18 @@ try { error(`${message} (run linear-release --help for usage)`); process.exit(1); } -const { command, releaseName, releaseVersion, stageName, baseRef, includePaths, jsonOutput, timeoutSeconds, logLevel } = - parsedArgs; +const { + command, + releaseName, + releaseVersion, + stageName, + baseRef, + includePaths, + links, + jsonOutput, + timeoutSeconds, + logLevel, +} = parsedArgs; const cliWarnings = getCLIWarnings(parsedArgs); setLogLevel(logLevel); if (jsonOutput) { @@ -101,6 +114,23 @@ function formatVersion(release: { version?: string } | null | undefined): string return release?.version ? `version: ${release.version}` : "no version set"; } +function formatLinkForLog(link: ReleaseLink): string { + if (link.label) { + return link.label; + } + + try { + const { hostname } = new URL(link.url); + return hostname.startsWith("www.") ? hostname.slice(4) : hostname; + } catch { + return "unlabeled"; + } +} + +function formatLinkSummary(linksToFormat: ReleaseLink[]): string { + return linksToFormat.length > 0 ? `, links [${linksToFormat.map(formatLinkForLog).join(", ")}]` : ""; +} + const logEnvironmentSummary = () => { info(`linear-release v${CLI_VERSION}`); if (releaseName) { @@ -250,13 +280,13 @@ async function syncCommand(): Promise<{ const repoInfo = getRepoInfo(); - const release = await syncRelease(issueReferences, revertedIssueReferences, prNumbers, repoInfo, debugSink); + const release = await syncRelease(issueReferences, revertedIssueReferences, prNumbers, repoInfo, debugSink, links); const issueIds = issueReferences.map((f) => f.identifier); const parts: string[] = []; if (issueIds.length > 0) parts.push(`issues [${issueIds.join(", ")}]`); if (prNumbers.length > 0) parts.push(`pull requests [${prNumbers.map((n) => `#${n}`).join(", ")}]`); - const attached = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests"; - info(`Synced to release ${release.name} (${formatVersion(release)}): ${attached}`); + const scanned = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests"; + info(`Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}`); if (scanBase.kind === "base-ref") { info(`Stored release baseline: ${(release.commitSha ?? currentCommit.commit).slice(0, 7)}`); } @@ -282,10 +312,13 @@ async function completeCommand(): Promise<{ const result = await completeRelease({ name: releaseName, version: releaseVersion, - commitSha, + commitSha: commitSha ?? undefined, + links, }); if (result.success) { - info(`Completed release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)})`); + info( + `Completed release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)})${formatLinkSummary(links)}`, + ); } else { throw new Error("Failed to complete release"); } @@ -317,6 +350,7 @@ async function updateCommand(): Promise<{ stage: stageName, version: releaseVersion, name: releaseName, + links, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -325,7 +359,7 @@ async function updateCommand(): Promise<{ if (result.success) { info( - `Updated release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)}) to stage ${result.release?.stageName}`, + `Updated release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)}) to stage ${result.release?.stageName}${formatLinkSummary(links)}`, ); } else { throw new Error("Failed to update release"); @@ -434,6 +468,7 @@ async function syncRelease( prNumbers: number[], repoInfo: RepoInfo | null, debugSink: DebugSink, + releaseLinks: ReleaseLink[], ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -469,6 +504,7 @@ async function syncRelease( commitSha: currentSha, issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, + links: releaseLinks.length > 0 ? releaseLinks : undefined, pullRequestReferences: prNumbers.map((number) => ({ repositoryOwner: owner, repositoryName: name, @@ -495,14 +531,15 @@ async function syncRelease( } async function completeRelease(options: { - name?: string | null; - version?: string | null; - commitSha?: string | null; + name?: string; + version?: string; + commitSha?: string; + links: ReleaseLink[]; }): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null; }> { - const { name, version, commitSha } = options; + const { name, version, commitSha, links: releaseLinks } = options; const response = await apiRequest( ` @@ -523,6 +560,7 @@ async function completeRelease(options: { name, version, commitSha, + links: releaseLinks.length > 0 ? releaseLinks : undefined, }, }, ); @@ -532,8 +570,9 @@ async function completeRelease(options: { async function updateReleaseByPipeline(options: { stage?: string; - version?: string | null; - name?: string | null; + version?: string; + name?: string; + links: ReleaseLink[]; }): Promise<{ success: boolean; release: { @@ -544,19 +583,11 @@ async function updateReleaseByPipeline(options: { stageName: string; } | null; }> { - const { stage, version, name } = options; - const versionInput = version ? `, version: "${version}"` : ""; - const stageInput = stage ? `, stage: "${stage}"` : ""; - const nameInput = name ? `, name: "${name}"` : ""; - - const inputParts = [versionInput, stageInput, nameInput] - .filter(Boolean) - .map((s) => s.slice(2)) - .join(", "); + const { stage, version, name, links: releaseLinks } = options; const response = await apiRequest( ` - mutation { - releaseUpdateByPipelineByAccessKey(input: { ${inputParts} }) { + mutation releaseUpdateByPipelineByAccessKey($input: ReleaseUpdateByPipelineInputBase!) { + releaseUpdateByPipelineByAccessKey(input: $input) { success release { id @@ -570,6 +601,14 @@ async function updateReleaseByPipeline(options: { } } `, + { + input: { + stage, + version, + name, + links: releaseLinks.length > 0 ? releaseLinks : undefined, + }, + }, ); const result = response.data.releaseUpdateByPipelineByAccessKey;