diff --git a/README.md b/README.md index 945e59f..235ddc0 100644 --- a/README.md +++ b/README.md @@ -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 `..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) | diff --git a/src/args.test.ts b/src/args.test.ts index cb1a49d..c680ab2 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -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/**"]); diff --git a/src/args.ts b/src/args.ts index b2782d1..73c7552 100644 --- a/src/args.ts +++ b/src/args.ts @@ -31,6 +31,7 @@ export type ParsedCLIArgs = { documents: ReleaseDocumentSpec[]; releaseNotes?: ReleaseNoteSpec; jsonOutput: boolean; + dryRun: boolean; timeoutSeconds: number; logLevel: LogLevel; }; @@ -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 }, @@ -228,6 +230,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { documents, releaseNotes, jsonOutput: values.json ?? false, + dryRun: values["dry-run"] ?? false, timeoutSeconds, logLevel, }; diff --git a/src/extractors.test.ts b/src/extractors.test.ts index fb14822..ab8fc32 100644 --- a/src/extractors.test.ts +++ b/src/extractors.test.ts @@ -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([ @@ -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", diff --git a/src/extractors.ts b/src/extractors.ts index 19d5bfc..2864400 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -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"), ]; /** diff --git a/src/index.ts b/src/index.ts index d295944..711877c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ Options: --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) + --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 @@ -119,6 +120,7 @@ const { documents: documentSpecs, releaseNotes: releaseNotesSpec, jsonOutput, + dryRun, timeoutSeconds, logLevel, } = parsedArgs; @@ -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, @@ -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)}`, ); @@ -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, @@ -435,6 +456,15 @@ async function updateCommand(): Promise<{ throw new Error("--stage= 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({