diff --git a/README.md b/README.md index f0d23f0..460719f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Creates a release or adds issues to the current release. This is the default com linear-release sync # Specify custom name and version -linear-release sync --name="Release 1.2.0" --version="1.2.0" +linear-release sync --name="Release 1.2.0" --release-version="1.2.0" ``` ### `complete` @@ -100,7 +100,7 @@ Marks a release as complete. Only applicable to scheduled pipelines, as continuo linear-release complete # Completes the release with the specified version -linear-release complete --version="1.2.0" +linear-release complete --release-version="1.2.0" ``` ### `update` @@ -112,7 +112,7 @@ Updates a release's deployment stage. Only applicable to scheduled pipelines, as linear-release update --stage="in review" # Updates the release with the specified version -linear-release update --stage="in review" --version="1.2.0" +linear-release update --stage="in review" --release-version="1.2.0" ``` ## Configuration @@ -125,13 +125,13 @@ linear-release update --stage="in review" --version="1.2.0" ### CLI Options -| Option | Commands | Description | -| ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--name` | `sync` | Custom release name. Defaults to short commit hash. | -| `--version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, 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 | +| Option | Commands | Description | +| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--name` | `sync` | Custom release name. Defaults to short commit hash. | +| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, 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 | ### JSON Output diff --git a/src/args.test.ts b/src/args.test.ts new file mode 100644 index 0000000..f36c512 --- /dev/null +++ b/src/args.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { parseCLIArgs } from "./args"; + +describe("parseCLIArgs", () => { + it("defaults command to sync when no positional given", () => { + const result = parseCLIArgs([]); + expect(result.command).toBe("sync"); + }); + + it("parses explicit sync command", () => { + const result = parseCLIArgs(["sync"]); + expect(result.command).toBe("sync"); + }); + + it("parses explicit complete command", () => { + const result = parseCLIArgs(["complete"]); + expect(result.command).toBe("complete"); + }); + + it("parses explicit update command", () => { + const result = parseCLIArgs(["update"]); + expect(result.command).toBe("update"); + }); + + it("parses --release-version", () => { + const result = parseCLIArgs(["--release-version", "1.2.0"]); + expect(result.releaseVersion).toBe("1.2.0"); + }); + + it("parses --release-version with = syntax", () => { + const result = parseCLIArgs(["--release-version=1.2.0"]); + expect(result.releaseVersion).toBe("1.2.0"); + }); + + it("parses --name", () => { + const result = parseCLIArgs(["--name", "Release 1.2.0"]); + expect(result.releaseName).toBe("Release 1.2.0"); + }); + + it("parses --stage", () => { + const result = parseCLIArgs(["--stage", "production"]); + expect(result.stageName).toBe("production"); + }); + + it("defaults --json to false", () => { + const result = parseCLIArgs([]); + expect(result.jsonOutput).toBe(false); + }); + + it("parses --json to true when passed", () => { + const result = parseCLIArgs(["--json"]); + expect(result.jsonOutput).toBe(true); + }); + + it("splits --include-paths by comma and trims whitespace", () => { + const result = parseCLIArgs(["--include-paths", "apps/web/** , packages/** , libs/core/**"]); + expect(result.includePaths).toEqual(["apps/web/**", "packages/**", "libs/core/**"]); + }); + + it("returns empty array for --include-paths with empty string", () => { + const result = parseCLIArgs(["--include-paths", ""]); + expect(result.includePaths).toEqual([]); + }); + + it("returns empty array when --include-paths is not provided", () => { + const result = parseCLIArgs([]); + expect(result.includePaths).toEqual([]); + }); + + it("parses command combined with multiple options", () => { + const result = parseCLIArgs(["update", "--stage=production", "--release-version", "1.2.0", "--json"]); + expect(result.command).toBe("update"); + expect(result.stageName).toBe("production"); + expect(result.releaseVersion).toBe("1.2.0"); + expect(result.jsonOutput).toBe(true); + }); + + it("strips empty entries from --include-paths with trailing/leading commas", () => { + const result = parseCLIArgs(["--include-paths", ",apps/web/**,packages/**,"]); + expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]); + }); + + it("throws on unknown flags (strict mode)", () => { + expect(() => parseCLIArgs(["--unknown-flag"])).toThrow(); + }); +}); diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..4881d43 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,30 @@ +import { parseArgs } from "node:util"; + +export function parseCLIArgs(argv: string[]) { + const { values, positionals } = parseArgs({ + args: argv, + options: { + name: { type: "string" }, + "release-version": { type: "string" }, + stage: { type: "string" }, + "include-paths": { type: "string" }, + json: { type: "boolean", default: false }, + }, + allowPositionals: true, + strict: true, + }); + + return { + command: positionals[0] || "sync", + releaseName: values.name, + releaseVersion: values["release-version"], + stageName: values.stage, + includePaths: values["include-paths"] + ? values["include-paths"] + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + : [], + jsonOutput: values.json ?? false, + }; +} diff --git a/src/index.ts b/src/index.ts index 8aabdb2..3eee147 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { PullRequestSource, RepoInfo, } from "./types"; +import { parseCLIArgs } from "./args"; import { log, setStderr } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; @@ -39,7 +40,7 @@ Commands: Options: --name= Custom release name (sync only) - --version= Release version identifier + --release-version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --json Output result as JSON @@ -51,7 +52,7 @@ Environment: Examples: linear-release sync - linear-release sync --name="Release 1.2.0" --version="1.2.0" + linear-release sync --name="Release 1.2.0" --release-version="1.2.0" linear-release complete linear-release update --stage=production linear-release sync --include-paths="apps/web/**,packages/**" @@ -60,37 +61,21 @@ Examples: } const accessKey: string = process.env.LINEAR_ACCESS_KEY || ""; -const command: string = process.argv[2] || "sync"; if (!accessKey) { console.error("Error: LINEAR_ACCESS_KEY environment variable must be set"); process.exit(1); } -// Parse --include-paths=path1,path2 CLI argument for filtering commits by changed files -const includePathsArg = process.argv.find((arg) => arg.startsWith("--include-paths=")); -const includePaths: string[] | null = includePathsArg - ? includePathsArg - .replace("--include-paths=", "") - .split(",") - .map((p) => p.trim()) - .filter((p) => p.length > 0) - : null; - -// Parse --name= CLI argument for custom release name -const nameArg = process.argv.find((arg) => arg.startsWith("--name=")); -const releaseName: string | null = nameArg ? nameArg.replace("--name=", "").trim() : null; - -// Parse --version= CLI argument for custom release version -const versionArg = process.argv.find((arg) => arg.startsWith("--version=")); -const releaseVersion: string | null = versionArg ? versionArg.replace("--version=", "").trim() : null; - -// Parse --stage= CLI argument for update command -const stageArg = process.argv.find((arg) => arg.startsWith("--stage=")); -const stageName: string | null = stageArg ? stageArg.replace("--stage=", "").trim() : null; - -// Parse --json flag for structured JSON output -const jsonOutput = process.argv.includes("--json"); +let parsedArgs: ReturnType; +try { + parsedArgs = parseCLIArgs(process.argv.slice(2)); +} catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + console.error("Run linear-release --help for usage information."); + process.exit(1); +} +const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput } = parsedArgs; if (jsonOutput) { setStderr(true); }