Skip to content

Commit 707c2b7

Browse files
oldiumclaude
andauthored
Add --dry-run option and recognize KEY-N: subject prefix (#97)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Oldřich Jedlička <oldium.pro@gmail.com>
1 parent 435516f commit 707c2b7

6 files changed

Lines changed: 73 additions & 9 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
163163
| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. |
164164
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
165165
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
166+
| `--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. |
166167
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
167168
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
168169
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |

src/args.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ describe("parseCLIArgs", () => {
6363
expect(result.jsonOutput).toBe(true);
6464
});
6565

66+
it("defaults --dry-run to false", () => {
67+
const result = parseCLIArgs([]);
68+
expect(result.dryRun).toBe(false);
69+
});
70+
71+
it("parses --dry-run to true when passed", () => {
72+
const result = parseCLIArgs(["--dry-run"]);
73+
expect(result.dryRun).toBe(true);
74+
});
75+
6676
it("splits --include-paths by comma and trims whitespace", () => {
6777
const result = parseCLIArgs(["--include-paths", "apps/web/** , packages/** , libs/core/**"]);
6878
expect(result.includePaths).toEqual(["apps/web/**", "packages/**", "libs/core/**"]);

src/args.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type ParsedCLIArgs = {
3131
documents: ReleaseDocumentSpec[];
3232
releaseNotes?: ReleaseNoteSpec;
3333
jsonOutput: boolean;
34+
dryRun: boolean;
3435
timeoutSeconds: number;
3536
logLevel: LogLevel;
3637
};
@@ -138,6 +139,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
138139
"release-notes": { type: "string", multiple: true },
139140
"release-notes-file": { type: "string", multiple: true },
140141
json: { type: "boolean", default: false },
142+
"dry-run": { type: "boolean", default: false },
141143
timeout: { type: "string" },
142144
quiet: { type: "boolean", default: false },
143145
verbose: { type: "boolean", default: false },
@@ -228,6 +230,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
228230
documents,
229231
releaseNotes,
230232
jsonOutput: values.json ?? false,
233+
dryRun: values["dry-run"] ?? false,
231234
timeoutSeconds,
232235
logLevel,
233236
};

src/extractors.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,13 @@ describe("commit message magic word behavior", () => {
220220
expect(ids(result)).toEqual(["LIN-123"]);
221221
});
222222

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

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

447+
it("extracts identifier from a KEY-N: prefix (colon then space)", () => {
448+
const result = extractLinearIssueIdentifiersForCommit({
449+
sha: "abc",
450+
branchName: null,
451+
message: "ENG-123: My change",
452+
});
453+
expect(ids(result)).toEqual(["ENG-123"]);
454+
});
455+
456+
it("does not extract KEY-N: prefix without a space after the colon", () => {
457+
const result = extractLinearIssueIdentifiersForCommit({
458+
sha: "abc",
459+
branchName: null,
460+
message: "ENG-123:my change",
461+
});
462+
expect(ids(result)).toEqual([]);
463+
});
464+
447465
it("does not extract bare prefix when the subject does not start with it", () => {
448466
const result = extractLinearIssueIdentifiersForCommit({
449467
sha: "abc",

src/extractors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ const COMMON_SUBJECT_PATTERNS: RegExp[] = [
4646
new RegExp(`^\\s*\\[(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\]`, "i"),
4747
// `(ENG-123) My change`
4848
new RegExp(`^\\s*\\((\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})\\)`, "i"),
49-
// `ENG-123 My change`
50-
new RegExp(`^\\s*(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})(?=\\s)`, "i"),
49+
// `ENG-123 My change` or `ENG-123: My change` (colon is allowed before the
50+
// whitespace; `ENG-123:foo` without the space stays unmatched to keep the
51+
// delimiter unambiguous).
52+
new RegExp(`^\\s*(\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9})(?=:?\\s)`, "i"),
5153
];
5254

5355
/**

src/index.ts

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

359361
const repoInfo = getRepoInfo();
360362

363+
const issueIds = issueReferences.map((f) => f.identifier);
364+
const parts: string[] = [];
365+
if (issueIds.length > 0) parts.push(`issues [${issueIds.join(", ")}]`);
366+
if (prNumbers.length > 0) parts.push(`pull requests [${prNumbers.map((n) => `#${n}`).join(", ")}]`);
367+
const scanned = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
368+
369+
if (dryRun) {
370+
const targetName = releaseName ?? "(server-assigned)";
371+
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
372+
info(
373+
`[dry-run] Would sync release ${targetName} (${versionPart}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
374+
);
375+
return null;
376+
}
377+
361378
const release = await syncRelease(
362379
issueReferences,
363380
revertedIssueReferences,
@@ -368,11 +385,6 @@ async function syncCommand(): Promise<{
368385
documents,
369386
releaseNotes,
370387
);
371-
const issueIds = issueReferences.map((f) => f.identifier);
372-
const parts: string[] = [];
373-
if (issueIds.length > 0) parts.push(`issues [${issueIds.join(", ")}]`);
374-
if (prNumbers.length > 0) parts.push(`pull requests [${prNumbers.map((n) => `#${n}`).join(", ")}]`);
375-
const scanned = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
376388
info(
377389
`Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
378390
);
@@ -398,6 +410,15 @@ async function completeCommand(): Promise<{
398410
const currentCommit = await getCurrentGitInfo();
399411
const commitSha = currentCommit.commit;
400412

413+
if (dryRun) {
414+
const targetName = releaseName ?? "(current release)";
415+
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
416+
info(
417+
`[dry-run] Would complete release ${targetName} (${versionPart})${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
418+
);
419+
return null;
420+
}
421+
401422
const result = await completeRelease({
402423
name: releaseName,
403424
version: releaseVersion,
@@ -435,6 +456,15 @@ async function updateCommand(): Promise<{
435456
throw new Error("--stage=<stage-name> is required for the update command");
436457
}
437458

459+
if (dryRun) {
460+
const targetName = releaseName ?? "(current release)";
461+
const versionPart = releaseVersion ? `version: ${releaseVersion}` : "no version set";
462+
info(
463+
`[dry-run] Would update release ${targetName} (${versionPart}) to stage ${stageName}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`,
464+
);
465+
return null;
466+
}
467+
438468
let result;
439469
try {
440470
result = await updateReleaseByPipeline({

0 commit comments

Comments
 (0)