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

Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
13 changes: 13 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseArgs } from "node:util";
import { LogLevel } from "./log";

export type ParsedCLIArgs = {
command: string;
Expand All @@ -8,6 +9,7 @@ export type ParsedCLIArgs = {
includePaths: string[];
jsonOutput: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
};

export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -48,6 +60,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
: [],
jsonOutput: values.json ?? false,
timeoutSeconds,
logLevel,
};
}

Expand Down
16 changes: 8 additions & 8 deletions src/extractors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { log } from "./log";
import { verbose } from "./log";
import { CommitContext } from "./types";

const MAX_KEY_LENGTH = 7;
Expand Down Expand Up @@ -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 [];
}

Expand Down Expand Up @@ -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 [];
}

Expand All @@ -201,22 +201,22 @@ 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));
}

// Only use fallback if no matches from squash/merge formats
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));
}
}
Expand Down
30 changes: 15 additions & 15 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 [];
}

Expand Down Expand Up @@ -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(", ")}` : ""),
);
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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);
}
}
Expand Down
Loading