Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. |
| `--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. |
| `--dry-run` | `sync`, `complete`, `update` | Scan commits and call read-only Linear APIs (e.g. recent releases, pipeline settings), but skip the create/update mutations. Logs the action that would have been taken. No release is created or modified. |
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
Expand Down
10 changes: 10 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ describe("parseCLIArgs", () => {
expect(result.jsonOutput).toBe(true);
});

it("defaults --dry-run to false", () => {
const result = parseCLIArgs([]);
expect(result.dryRun).toBe(false);
});

it("parses --dry-run to true when passed", () => {
const result = parseCLIArgs(["--dry-run"]);
expect(result.dryRun).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/**"]);
Expand Down
3 changes: 3 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type ParsedCLIArgs = {
documents: ReleaseDocumentSpec[];
releaseNotes?: ReleaseNoteSpec;
jsonOutput: boolean;
dryRun: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
};
Expand Down Expand Up @@ -138,6 +139,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
"release-notes": { type: "string", multiple: true },
"release-notes-file": { type: "string", multiple: true },
json: { type: "boolean", default: false },
"dry-run": { type: "boolean", default: false },
timeout: { type: "string" },
quiet: { type: "boolean", default: false },
verbose: { type: "boolean", default: false },
Expand Down Expand Up @@ -228,6 +230,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
documents,
releaseNotes,
jsonOutput: values.json ?? false,
dryRun: values["dry-run"] ?? false,
timeoutSeconds,
logLevel,
};
Expand Down
22 changes: 20 additions & 2 deletions src/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,13 @@ describe("commit message magic word behavior", () => {
expect(ids(result)).toEqual(["LIN-123"]);
});

it("does not extract key in title without keyword", () => {
it("extracts KEY-N: prefix at the start of the subject", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "LIN-123: Fix something",
});
expect(ids(result)).toEqual([]);
expect(ids(result)).toEqual(["LIN-123"]);
});

it.each([
Expand Down Expand Up @@ -444,6 +444,24 @@ describe("bracketed identifier in commit subject", () => {
expect(ids(result)).toEqual(["ENG-123"]);
});

it("extracts identifier from a KEY-N: prefix (colon then space)", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "ENG-123: My change",
});
expect(ids(result)).toEqual(["ENG-123"]);
});

it("does not extract KEY-N: prefix without a space after the colon", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
branchName: null,
message: "ENG-123:my change",
});
expect(ids(result)).toEqual([]);
});

it("does not extract bare prefix when the subject does not start with it", () => {
const result = extractLinearIssueIdentifiersForCommit({
sha: "abc",
Expand Down
6 changes: 4 additions & 2 deletions src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ const COMMON_SUBJECT_PATTERNS: RegExp[] = [
new RegExp(`^\\s*\\[(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\]`, "i"),
// `(ENG-123) My change`
new RegExp(`^\\s*\\((\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\)`, "i"),
// `ENG-123 My change`
new RegExp(`^\\s*(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})(?=\\s)`, "i"),
// `ENG-123 My change` or `ENG-123: My change` (colon is allowed before the
// whitespace; `ENG-123:foo` without the space stays unmatched to keep the
// delimiter unambiguous).
new RegExp(`^\\s*(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})(?=:?\\s)`, "i"),
];

/**
Expand Down
40 changes: 35 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Options:
--base-ref=<ref> Override sync scan base (exclusive; scans <ref>..HEAD)
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
--json Output result as JSON (logs emitted as JSON Lines on stderr)
--dry-run Scan and call read-only Linear APIs, but skip create/update mutations
--quiet Suppress info-level output (warnings and errors still printed)
--verbose Print detailed progress including debug diagnostics
-v, --version Show version number
Expand Down Expand Up @@ -119,6 +120,7 @@ const {
documents: documentSpecs,
releaseNotes: releaseNotesSpec,
jsonOutput,
dryRun,
timeoutSeconds,
logLevel,
} = parsedArgs;
Expand Down Expand Up @@ -358,6 +360,21 @@ async function syncCommand(): Promise<{

const repoInfo = getRepoInfo();

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 scanned = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";

if (dryRun) {
const targetName = releaseName ?? "(server-assigned)";
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
info(
`[dry-run] Would sync release ${targetName} (${versionPart}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
);
return null;
}

const release = await syncRelease(
issueReferences,
revertedIssueReferences,
Expand All @@ -368,11 +385,6 @@ async function syncCommand(): Promise<{
documents,
releaseNotes,
);
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 scanned = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
info(
`Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
);
Expand All @@ -398,6 +410,15 @@ async function completeCommand(): Promise<{
const currentCommit = await getCurrentGitInfo();
const commitSha = currentCommit.commit;

if (dryRun) {
const targetName = releaseName ?? "(current release)";
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
info(
`[dry-run] Would complete release ${targetName} (${versionPart})${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
);
return null;
}

const result = await completeRelease({
name: releaseName,
version: releaseVersion,
Expand Down Expand Up @@ -435,6 +456,15 @@ async function updateCommand(): Promise<{
throw new Error("--stage=<stage-name> is required for the update command");
}

if (dryRun) {
const targetName = releaseName ?? "(current release)";
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
info(
`[dry-run] Would update release ${targetName} (${versionPart}) to stage ${stageName}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
);
return null;
}

let result;
try {
result = await updateReleaseByPipeline({
Expand Down
Loading