diff --git a/README.md b/README.md index e921ef2..9543182 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ linear-release update --stage="in review" --release-version="1.2.0" | `--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 | +| `--quiet` | `sync`, `complete`, `update` | Only print errors | +| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | ### Command Targeting @@ -172,6 +174,18 @@ linear-release sync --json When no release is created (e.g. no commits found), `--json` outputs `{"release":null}`. +### Log Levels + +By default, the CLI prints key results like the number of commits scanned and issues linked. Use log level flags to control verbosity: + +| Flag | Output | +| ----------- | -------------------------------------------------------------------- | +| `--quiet` | Errors only — ideal for silent CI jobs | +| _(default)_ | Key results (issues found, release created, etc) | +| `--verbose` | Detailed progress (config, shallow-clone fetches, debug diagnostics) | + +Only one log level flag can be used at a time. + ### Path Filtering Use `--include-paths` to only include commits that modify specific files. This is useful for monorepos. diff --git a/src/args.test.ts b/src/args.test.ts index 303b345..904b154 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getCLIWarnings, parseCLIArgs } from "./args"; +import { LogLevel } from "./log"; describe("parseCLIArgs", () => { it("defaults command to sync when no positional given", () => { @@ -125,4 +126,23 @@ describe("parseCLIArgs", () => { it("throws on negative --timeout", () => { expect(() => parseCLIArgs(["--timeout=-5"])).toThrow('Invalid --timeout value: "-5"'); }); + + it("defaults logLevel to Default", () => { + const result = parseCLIArgs([]); + expect(result.logLevel).toBe(LogLevel.Default); + }); + + it("parses --quiet to LogLevel.Quiet", () => { + const result = parseCLIArgs(["--quiet"]); + expect(result.logLevel).toBe(LogLevel.Quiet); + }); + + it("parses --verbose to LogLevel.Verbose", () => { + const result = parseCLIArgs(["--verbose"]); + expect(result.logLevel).toBe(LogLevel.Verbose); + }); + + it("throws when --quiet and --verbose are both passed", () => { + expect(() => parseCLIArgs(["--quiet", "--verbose"])).toThrow("Conflicting log level flags"); + }); }); diff --git a/src/args.ts b/src/args.ts index 958f0e9..9c369e8 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,5 @@ import { parseArgs } from "node:util"; +import { LogLevel } from "./log"; export type ParsedCLIArgs = { command: string; @@ -8,6 +9,7 @@ export type ParsedCLIArgs = { includePaths: string[]; jsonOutput: boolean; timeoutSeconds: number; + logLevel: LogLevel; }; export function parseCLIArgs(argv: string[]): ParsedCLIArgs { @@ -20,6 +22,8 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "include-paths": { type: "string" }, json: { type: "boolean", default: false }, timeout: { type: "string" }, + quiet: { type: "boolean", default: false }, + verbose: { type: "boolean", default: false }, }, allowPositionals: true, strict: true, @@ -35,6 +39,14 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { timeoutSeconds = parsed; } + if (values.quiet && values.verbose) { + throw new Error("Conflicting log level flags: --quiet, --verbose. Use only one."); + } + + let logLevel = LogLevel.Default; + if (values.quiet) logLevel = LogLevel.Quiet; + else if (values.verbose) logLevel = LogLevel.Verbose; + return { command: positionals[0] || "sync", releaseName: values.name, @@ -48,6 +60,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { : [], jsonOutput: values.json ?? false, timeoutSeconds, + logLevel, }; } diff --git a/src/extractors.ts b/src/extractors.ts index 44f552a..53c4563 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -1,4 +1,4 @@ -import { log } from "./log"; +import { verbose } from "./log"; import { CommitContext } from "./types"; const MAX_KEY_LENGTH = 7; @@ -143,12 +143,12 @@ export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): E // count its identifiers as "added". Even depth = revert-of-revert (re-add). const { depth: branchDepth, inner: strippedBranch } = parseRevertBranch(commit.branchName ?? ""); if (branchDepth % 2 === 1) { - log(`Skipping revert branch "${commit.branchName}" (depth ${branchDepth}) for commit ${commit.sha}`); + verbose(`Skipping revert branch "${commit.branchName}" (depth ${branchDepth}) for commit ${commit.sha}`); return []; } const { depth: messageDepth } = parseRevertMessage(commit.message ?? ""); if (messageDepth % 2 === 1) { - log(`Skipping revert message (depth ${messageDepth}) for commit ${commit.sha}`); + verbose(`Skipping revert message (depth ${messageDepth}) for commit ${commit.sha}`); return []; } @@ -184,14 +184,14 @@ export function extractPullRequestNumbersForCommit(commit: CommitContext): numbe // Skip reverts - they reference the original PR, not a new one if (/^Revert "/i.test(message)) { - log(`Skipping revert commit ${commit.sha} with message: "${message}"`); + verbose(`Skipping revert commit ${commit.sha} with message: "${message}"`); return []; } // Revert merge commits reference the original PR number, not a new one. // Even depth (revert-of-revert) falls through to normal extraction. if (getRevertBranchDepth(commit.branchName) % 2 === 1) { - log(`Skipping revert merge commit ${commit.sha}`); + verbose(`Skipping revert merge commit ${commit.sha}`); return []; } @@ -201,14 +201,14 @@ export function extractPullRequestNumbersForCommit(commit: CommitContext): numbe const title = message.split(/\r?\n/)[0] ?? ""; const squashMatch = title.match(/\(#(\d+)\)$/); if (squashMatch) { - log(`Found PR number ${squashMatch[1]} in commit ${commit.sha} using squash format: "${message}"`); + verbose(`Found PR number ${squashMatch[1]} in commit ${commit.sha} using squash format: "${message}"`); prNumbers.push(Number.parseInt(squashMatch[1]!, 10)); } // GitHub merge: "Merge pull request #123 from ..." - must be at start const mergeMatch = message.match(/^Merge pull request #(\d+)/i); if (mergeMatch) { - log(`Found PR number ${mergeMatch[1]} in commit ${commit.sha} using merge format: "${message}"`); + verbose(`Found PR number ${mergeMatch[1]} in commit ${commit.sha} using merge format: "${message}"`); prNumbers.push(Number.parseInt(mergeMatch[1]!, 10)); } @@ -216,7 +216,7 @@ export function extractPullRequestNumbersForCommit(commit: CommitContext): numbe if (prNumbers.length === 0) { const messageMatches = message.matchAll(/#(\d+)/g); for (const match of messageMatches) { - log(`Found PR number ${match[1]} in commit ${commit.sha} by extracting from message: "${message}"`); + verbose(`Found PR number ${match[1]} in commit ${commit.sha} by extracting from message: "${message}"`); prNumbers.push(Number.parseInt(match[1]!, 10)); } } diff --git a/src/git.ts b/src/git.ts index 1c06fe2..1949d57 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,6 +1,6 @@ import { execSync } from "node:child_process"; import type { CommitContext, GitInfo, RepoInfo } from "./types"; -import { log } from "./log"; +import { error as logError, verbose, warn } from "./log"; /** Strips leading "./" or "/" so paths are clean for git pathspec. */ export function normalizePathspec(pattern: string): string { @@ -102,7 +102,7 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean // Only log unexpected errors, not "commit not found" which is expected const message = error instanceof Error ? error.message : String(error); if (!message.includes("Not a valid object")) { - log(`commitExists: Unexpected error checking ${sha}: ${message}`); + warn(`commitExists: Unexpected error checking ${sha}: ${message}`); } return false; } @@ -115,7 +115,7 @@ const SHA_PATTERN = /^[0-9a-f]{7,40}$/i; */ export function isMergeCommit(sha: string, cwd: string = process.cwd()): boolean { if (!SHA_PATTERN.test(sha)) { - log(`isMergeCommit: Invalid SHA format "${sha}"`); + warn(`isMergeCommit: Invalid SHA format "${sha}"`); return false; } @@ -131,7 +131,7 @@ export function isMergeCommit(sha: string, cwd: string = process.cwd()): boolean return parentHashes.includes(" "); } catch (error) { const message = error instanceof Error ? error.message : String(error); - log(`isMergeCommit: Failed to check ${sha}: ${message}`); + warn(`isMergeCommit: Failed to check ${sha}: ${message}`); return false; } } @@ -165,7 +165,7 @@ function parseCommitChunk(chunk: string): CommitContext { */ export function getCommitContext(sha: string, cwd: string = process.cwd()): CommitContext | null { if (!SHA_PATTERN.test(sha)) { - log(`getCommitContext: Invalid SHA format "${sha}"`); + warn(`getCommitContext: Invalid SHA format "${sha}"`); return null; } @@ -178,14 +178,14 @@ export function getCommitContext(sha: string, cwd: string = process.cwd()): Comm const chunk = output.split("\x1e")[0]; if (!chunk || chunk.trim().length === 0) { - log(`getCommitContext: Empty output for ${sha}`); + warn(`getCommitContext: Empty output for ${sha}`); return null; } return parseCommitChunk(chunk); } catch (error) { const message = error instanceof Error ? error.message : String(error); - log(`getCommitContext: Failed to get context for ${sha}: ${message}`); + warn(`getCommitContext: Failed to get context for ${sha}: ${message}`); return null; } } @@ -212,14 +212,14 @@ function ensureCommitAvailable(sha: string, cwd: string): void { { command: "git fetch --unshallow origin", label: "Fetching full history" }, ]; - log(`Commit ${sha} not in local history (likely shallow clone)`); + verbose(`Commit ${sha} not in local history (likely shallow clone)`); for (const { command, label } of strategies) { - log(label); + verbose(label); try { execSync(command, { cwd, stdio: "pipe" }); if (commitExists(sha, cwd)) { - log(`Found commit ${sha}`); + verbose(`Found commit ${sha}`); return; } } catch { @@ -250,11 +250,11 @@ export function getCommitContextsBetweenShas( const { includePaths = null, cwd = process.cwd() } = options; if (!SHA_PATTERN.test(fromSha)) { - log(`getCommitContextsBetweenShas: Invalid fromSha format "${fromSha}"`); + warn(`getCommitContextsBetweenShas: Invalid fromSha format "${fromSha}"`); return []; } if (!SHA_PATTERN.test(toSha)) { - log(`getCommitContextsBetweenShas: Invalid toSha format "${toSha}"`); + warn(`getCommitContextsBetweenShas: Invalid toSha format "${toSha}"`); return []; } @@ -294,7 +294,7 @@ export function getCommitContextsBetweenShas( } if (commits.length === 0) { - log( + verbose( `getCommitContextsBetweenShas: No commits found between ${fromSha}..${toSha}` + (includePaths?.length ? ` with paths: ${includePaths.join(", ")}` : ""), ); @@ -361,7 +361,7 @@ export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd return parseRepoUrl(url); } catch (error) { - console.error(`Error getting repo info: ${error}`); + logError(`Error getting repo info: ${error}`); return null; } } @@ -378,7 +378,7 @@ export function getPullRequestNumbers(commits: CommitContext[]): number[] { for (const match of matches) { const prNumber = Number.parseInt(match[1]!, 10); if (!Number.isNaN(prNumber)) { - log(`Found pull request number ${prNumber} in commit ${commit.sha}`); + verbose(`Found pull request number ${prNumber} in commit ${commit.sha}`); prNumbers.add(prNumber); } } diff --git a/src/index.ts b/src/index.ts index b6f8feb..7f5fd79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { RepoInfo, } from "./types"; import { getCLIWarnings, parseCLIArgs } from "./args"; -import { log, setStderr } from "./log"; +import { error, info, setLogLevel, setStderr, verbose, warn } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; @@ -44,6 +44,8 @@ Options: --include-paths= Filter commits by file paths (comma-separated globs) --timeout= Abort if the operation exceeds this duration (default: 60) --json Output result as JSON + --quiet Only print errors + --verbose Print detailed progress including debug diagnostics -v, --version Show version number -h, --help Show this help message @@ -75,28 +77,28 @@ try { console.error("Run linear-release --help for usage information."); process.exit(1); } -const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds } = parsedArgs; +const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } = + parsedArgs; const cliWarnings = getCLIWarnings(parsedArgs); +setLogLevel(logLevel); if (jsonOutput) { setStderr(true); } const logEnvironmentSummary = () => { - log("Using access key authentication"); - if (releaseName) { if (command === "sync") { - log(`Using custom release name: ${releaseName}`); + info(`Using custom release name: ${releaseName}`); } } if (releaseVersion) { - log(`Using custom release version: ${releaseVersion}`); + info(`Using custom release version: ${releaseVersion}`); } - for (const warning of cliWarnings) { - log(`Warning: ${warning}`); + for (const w of cliWarnings) { + warn(`Warning: ${w}`); } - log(`Running in ${process.env.NODE_ENV === "development" ? "development" : "production"} mode`); + verbose(`Running in ${process.env.NODE_ENV === "development" ? "development" : "production"} mode`); }; const getDevApiUrl = () => { @@ -131,9 +133,9 @@ async function syncCommand(): Promise<{ let effectiveIncludePaths: string[] | null; if (includePaths && includePaths.length > 0) { effectiveIncludePaths = includePaths; - log(`Using CLI --include-paths: ${JSON.stringify(effectiveIncludePaths)}`); + verbose(`Using CLI --include-paths: ${JSON.stringify(effectiveIncludePaths)}`); if (pipelineSettings.includePathPatterns.length > 0) { - log( + verbose( `Note: Pipeline has includePathPatterns configured ${JSON.stringify( pipelineSettings.includePathPatterns, )}, but CLI --include-paths takes precedence`, @@ -141,7 +143,7 @@ async function syncCommand(): Promise<{ } } else if (pipelineSettings.includePathPatterns.length > 0) { effectiveIncludePaths = pipelineSettings.includePathPatterns; - log(`Using pipeline includePathPatterns: ${JSON.stringify(effectiveIncludePaths)}`); + verbose(`Using pipeline includePathPatterns: ${JSON.stringify(effectiveIncludePaths)}`); } else { effectiveIncludePaths = null; } @@ -156,7 +158,7 @@ async function syncCommand(): Promise<{ let inspectingOnlyCurrentCommit = false; if (!commitExists(latestSha)) { - log( + warn( `Could not find sha ${latestSha} in the git history (it may be on a different branch or the repository history was not fully fetched)`, ); inspectingOnlyCurrentCommit = true; @@ -170,15 +172,15 @@ async function syncCommand(): Promise<{ if (inspectingOnlyCurrentCommit) { if (commits.length === 0) { if (effectiveIncludePaths?.length) { - log(`Current commit (${currentCommit.commit}) does not match the path filter`); + verbose(`Current commit (${currentCommit.commit}) does not match the path filter`); } else { - log(`Current commit (${currentCommit.commit}) could not be inspected`); + verbose(`Current commit (${currentCommit.commit}) could not be inspected`); } } else { - log(`Inspecting current commit (${currentCommit.commit})`); + verbose(`Inspecting current commit (${currentCommit.commit})`); } } else { - log( + info( `Found ${commits.length} ${pluralize(commits.length, "commit")} between ${latestSha} and ${currentCommit.commit}`, ); } @@ -187,7 +189,7 @@ async function syncCommand(): Promise<{ const reason = effectiveIncludePaths?.length ? `matching ${JSON.stringify(effectiveIncludePaths)}` : "in the computed range"; - log(`No commits found ${reason}. Skipping release creation.`); + info(`No commits found ${reason}. Skipping release creation.`); return null; } @@ -199,28 +201,28 @@ async function syncCommand(): Promise<{ effectiveIncludePaths, ); - log(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); + verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); if (issueReferences.length === 0) { - log("No issue keys found"); + info("No issue keys found"); } else { - log(`Retrieved issue keys: ${issueReferences.map((f) => f.identifier).join(", ")}`); + info(`Retrieved issue keys: ${issueReferences.map((f) => f.identifier).join(", ")}`); } if (revertedIssueReferences.length > 0) { - log(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`); + info(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`); } const repoInfo = getRepoInfo(); const release = await syncRelease(issueReferences, revertedIssueReferences, prNumbers, repoInfo, debugSink); - log( + info( `Issues [${issueReferences.map((f) => f.identifier).join(", ")}] and pull requests [${prNumbers.join( ", ", )}] have been added to release ${release.name}`, ); - log("Finished"); + info("Finished"); return { release: { id: release.id, name: release.name, version: release.version, url: release.url } }; } @@ -238,12 +240,12 @@ async function completeCommand(): Promise<{ commitSha, }); if (result.success) { - log(`Completed release ${result.release?.name ?? "(unknown)"}`); + info(`Completed release ${result.release?.name ?? "(unknown)"}`); } else { throw new Error("Failed to complete release"); } - log("Finished"); + info("Finished"); return result.release ? { @@ -278,12 +280,12 @@ async function updateCommand(): Promise<{ } if (result.success) { - log(`Updated release "${result.release?.name}" to stage "${result.release?.stageName}"`); + info(`Updated release "${result.release?.name}" to stage "${result.release?.stageName}"`); } else { throw new Error("Failed to update release"); } - log("Finished"); + info("Finished"); return result.release ? { @@ -323,9 +325,9 @@ async function getLatestSha(): Promise { // If we can't find a release or the latest release has no commit SHA, we will only inspect the current commit if (!latestRelease) { - log("Could not find latest release, assuming it's the first release, will only inspect the current commit"); + verbose("Could not find latest release, assuming it's the first release, will only inspect the current commit"); } else if (!latestRelease.commitSha) { - log("Latest release has no commit SHA, will only inspect the current commit"); + verbose("Latest release has no commit SHA, will only inspect the current commit"); } const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -507,8 +509,8 @@ async function main() { result = await updateCommand(); break; default: - console.error(`Unknown command: ${command}`); - console.error("Available commands: sync, complete, update"); + error(`Unknown command: ${command}`); + error("Available commands: sync, complete, update"); process.exit(1); } @@ -519,7 +521,7 @@ async function main() { const timeoutMs = timeoutSeconds * 1000; const timeout = setTimeout(() => { - console.error( + error( `Error: Operation timed out after ${timeoutSeconds}s. This may indicate a large repository or slow network. Use --timeout= to increase the limit.`, ); process.exit(1); @@ -527,8 +529,8 @@ const timeout = setTimeout(() => { timeout.unref(); main() - .catch((error) => { - console.error(`Error: ${error.message}`); + .catch((e) => { + error(`Error: ${e.message}`); process.exit(1); }) .finally(() => { diff --git a/src/log.ts b/src/log.ts index 3cb594c..b5eac15 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,15 +1,50 @@ +export enum LogLevel { + Quiet = 0, + Default = 1, + Verbose = 2, +} + +let currentLevel: LogLevel = LogLevel.Default; let useStderr = false; +export function setLogLevel(level: LogLevel) { + currentLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLevel; +} + export function setStderr(value: boolean) { useStderr = value; } -export function log(message: string) { - if (process.env.NODE_ENV !== "test") { - if (useStderr) { - process.stderr.write(`=> ${message}\n`); - } else { - console.log(`=> ${message}`); - } +function write(message: string) { + if (process.env.NODE_ENV === "test") return; + if (useStderr) { + process.stderr.write(`=> ${message}\n`); + } else { + console.log(`=> ${message}`); } } + +/** Always printed to stderr, no prefix. */ +export function error(message: string) { + if (process.env.NODE_ENV === "test") return; + process.stderr.write(`${message}\n`); +} + +/** Printed at Default level and above. */ +export function warn(message: string) { + if (currentLevel >= LogLevel.Default) write(message); +} + +/** Printed at Default level and above. Replaces the old `log()`. */ +export function info(message: string) { + if (currentLevel >= LogLevel.Default) write(message); +} + +/** Printed at Verbose level and above. */ +export function verbose(message: string) { + if (currentLevel >= LogLevel.Verbose) write(message); +} diff --git a/src/retry.ts b/src/retry.ts index 4749dca..e2e569f 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,5 +1,5 @@ import { LinearError, LinearErrorType, RatelimitedLinearError } from "@linear/sdk"; -import { log } from "./log"; +import { warn } from "./log"; const MAX_ATTEMPTS = 3; const BASE_DELAY_MS = 1000; @@ -47,7 +47,7 @@ export async function withRetry(fn: () => Promise): Promise { throw error; } const delay = getDelayMs(error, attempt); - log(`Request failed, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`); + warn(`Request failed, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } diff --git a/src/scan.ts b/src/scan.ts index 99a01bb..35a2fd1 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -3,7 +3,7 @@ import { extractPullRequestNumbersForCommit, extractRevertedIssueIdentifiersForCommit, } from "./extractors"; -import { log } from "./log"; +import { verbose } from "./log"; import { CommitContext, DebugSink, IssueReference, PullRequestSource } from "./types"; /** @@ -48,7 +48,7 @@ export function scanCommits( lastAction.set(identifier, "reverted"); revertedRefs.set(identifier, { identifier, commitSha: commit.sha }); - log(`Detected reverted issue key ${identifier} from commit ${commit.sha}`); + verbose(`Detected reverted issue key ${identifier} from commit ${commit.sha}`); } for (const { identifier, source } of extractLinearIssueIdentifiersForCommit(commit)) { @@ -63,7 +63,7 @@ export function scanCommits( lastAction.set(identifier, "added"); addedRefs.set(identifier, { identifier, commitSha: commit.sha }); - log( + verbose( `Detected issue key ${identifier} from ${source === "branch_name" ? `branch "${commit.branchName}"` : `message "${commit.message}"`}`, ); } @@ -77,7 +77,7 @@ export function scanCommits( value: commit.message ?? "", }; debugSink.pullRequests.push(prSource); - log(`Found pull request number ${prNumber} in commit ${commit.sha}`); + verbose(`Found pull request number ${prNumber} in commit ${commit.sha}`); } } }