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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`
Expand All @@ -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
Expand All @@ -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

Expand Down
86 changes: 86 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
39 changes: 12 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,7 +40,7 @@ Commands:

Options:
--name=<name> Custom release name (sync only)
--version=<version> Release version identifier
--release-version=<version> Release version identifier
--stage=<stage> Deployment stage (required for update)
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
--json Output result as JSON
Expand All @@ -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/**"
Expand All @@ -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=<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=<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=<stage-name> 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<typeof parseCLIArgs>;
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);
}
Expand Down