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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ linear-release update --stage="in review" --name="Release 1.2.0"
| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. |
| `--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 |
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
| `--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 Expand Up @@ -187,7 +187,7 @@ By default, the CLI prints key results like the number of commits scanned and is

| Flag | Output |
| ----------- | -------------------------------------------------------------------- |
| `--quiet` | Errors only — ideal for silent CI jobs |
| `--quiet` | Warnings and errors only — ideal for silent CI jobs |
| _(default)_ | Key results (issues found, release created, etc) |
| `--verbose` | Detailed progress (config, shallow-clone fetches, debug diagnostics) |

Expand Down
10 changes: 6 additions & 4 deletions src/base-sha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,25 @@ export function findBaseSha(candidates: Release[], headSha: string, deps: FindBa
for (const candidate of candidates) {
const sha = candidate.commitSha;
if (!sha) {
verbose(`findBaseSha: skipping ${candidate.name}: no commitSha`);
verbose(`Skipping base SHA candidate "${candidate.name}": no commit SHA`);
continue;
}
if (!deps.commitExists(sha)) {
try {
deps.ensureCommitAvailable(sha);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
verbose(`findBaseSha: skipping ${candidate.name} (${sha}): ${message}`);
verbose(`Skipping base SHA candidate "${candidate.name}" (${sha.slice(0, 7)}): ${message}`);
continue;
}
}
if (!deps.isAncestor(sha, headSha)) {
verbose(`findBaseSha: skipping ${candidate.name} (${sha}): not an ancestor of ${headSha}`);
verbose(
`Skipping base SHA candidate "${candidate.name}" (${sha.slice(0, 7)}): not an ancestor of ${headSha.slice(0, 7)}`,
);
continue;
}
verbose(`findBaseSha: using ${candidate.name} (${sha})`);
verbose(`Using base SHA from release "${candidate.name}" (${sha.slice(0, 7)})`);
return { kind: "found", sha };
}
return { kind: "fallback" };
Expand Down
12 changes: 6 additions & 6 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,14 @@ function parseCommitChunk(chunk: string): CommitContext {
*/
export function getCommitContext(sha: string, cwd: string = process.cwd()): CommitContext | null {
if (!SHA_PATTERN.test(sha)) {
warn(`getCommitContext: Invalid SHA format "${sha}"`);
warn(`Invalid commit SHA format "${sha}"`);
return null;
}
try {
return runLog(`-1 ${sha}`, cwd)[0] ?? null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warn(`getCommitContext: Failed to get context for ${sha}: ${message}`);
warn(`Failed to read commit ${sha.slice(0, 7)}: ${message}`);
return null;
}
}
Expand Down Expand Up @@ -328,11 +328,11 @@ export function getCommitContextsBetweenShas(
const { includePaths = null, cwd = process.cwd() } = options;

if (!SHA_PATTERN.test(fromSha)) {
warn(`getCommitContextsBetweenShas: Invalid fromSha format "${fromSha}"`);
warn(`Invalid "from" SHA format "${fromSha}"`);
return [];
}
if (!SHA_PATTERN.test(toSha)) {
warn(`getCommitContextsBetweenShas: Invalid toSha format "${toSha}"`);
warn(`Invalid "to" SHA format "${toSha}"`);
return [];
}

Expand All @@ -347,7 +347,7 @@ export function getCommitContextsBetweenShas(

if (commits.length === 0) {
verbose(
`getCommitContextsBetweenShas: No commits found between ${fromSha}..${toSha}` +
`No commits found between ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}` +
(includePaths?.length ? ` with paths: ${includePaths.join(", ")}` : ""),
);
}
Expand Down Expand Up @@ -416,7 +416,7 @@ export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd

return parseRepoUrl(url);
} catch (error) {
logError(`Error getting repo info: ${error}`);
logError(`Failed to read repo info: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
Expand Down
58 changes: 26 additions & 32 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
RepoInfo,
} from "./types";
import { getCLIWarnings, parseCLIArgs } from "./args";
import { error, info, setLogLevel, setStderr, verbose, warn } from "./log";
import { error, info, setJsonMode, setLogLevel, setStderr, verbose, warn } from "./log";
import { pluralize } from "./util";
import { buildUserAgent } from "./user-agent";
import { withRetry } from "./retry";
Expand Down Expand Up @@ -53,8 +53,8 @@ Options:
--stage=<stage> Deployment stage (required for update)
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
--json Output result as JSON
--quiet Only print errors
--json Output result as JSON (logs emitted as JSON Lines on stderr)
--quiet Suppress info-level output (warnings and errors still printed)
--verbose Print detailed progress including debug diagnostics
-v, --version Show version number
-h, --help Show this help message
Expand All @@ -75,16 +75,16 @@ Examples:
const accessKey: string = process.env.LINEAR_ACCESS_KEY || "";

if (!accessKey) {
console.error("Error: LINEAR_ACCESS_KEY environment variable must be set");
error("LINEAR_ACCESS_KEY environment variable must be set");
process.exit(1);
}

let parsedArgs: ReturnType<typeof parseCLIArgs>;
try {
parsedArgs = parseCLIArgs(process.argv.slice(2));
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
console.error("Run linear-release --help for usage information.");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
error(`${message} (run linear-release --help for usage)`);
process.exit(1);
}
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
Expand All @@ -93,20 +93,24 @@ const cliWarnings = getCLIWarnings(parsedArgs);
setLogLevel(logLevel);
if (jsonOutput) {
setStderr(true);
setJsonMode(true);
}

function formatVersion(release: { version?: string } | null | undefined): string {
return release?.version ? `version: ${release.version}` : "no version set";
}

const logEnvironmentSummary = () => {
info(`linear-release v${CLI_VERSION}`);
if (releaseName) {
info(`Using custom release name: ${releaseName}`);
}
if (releaseVersion) {
info(`Using custom release version: ${releaseVersion}`);
}
for (const w of cliWarnings) {
warn(`Warning: ${w}`);
warn(w);
}

verbose(`Running in ${process.env.NODE_ENV === "development" ? "development" : "production"} mode`);
};

const getDevApiUrl = () => {
Expand Down Expand Up @@ -192,7 +196,7 @@ async function syncCommand(): Promise<{
}
} else {
info(
`Found ${commits.length} ${pluralize(commits.length, "commit")} between ${latestSha} and ${currentCommit.commit}`,
`Found ${commits.length} ${pluralize(commits.length, "commit")} between ${latestSha.slice(0, 7)} and ${currentCommit.commit.slice(0, 7)}`,
);
}

Expand All @@ -214,26 +218,19 @@ async function syncCommand(): Promise<{

verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`);

if (issueReferences.length === 0) {
info("No issue keys found");
} else {
info(`Retrieved issue keys: ${issueReferences.map((f) => f.identifier).join(", ")}`);
}

if (revertedIssueReferences.length > 0) {
info(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`);
}

const repoInfo = getRepoInfo();

const release = await syncRelease(issueReferences, revertedIssueReferences, prNumbers, repoInfo, debugSink);
info(
`Issues [${issueReferences.map((f) => f.identifier).join(", ")}] and pull requests [${prNumbers.join(
", ",
)}] have been added to release ${release.name}`,
);

info("Finished");
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 attached = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
info(`Synced to release ${release.name} (${formatVersion(release)}): ${attached}`);

return {
release: {
Expand All @@ -259,13 +256,11 @@ async function completeCommand(): Promise<{
commitSha,
});
if (result.success) {
info(`Completed release ${result.release?.name ?? "(unknown)"}`);
info(`Completed release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)})`);
} else {
throw new Error("Failed to complete release");
}

info("Finished");

return result.release
? {
release: {
Expand Down Expand Up @@ -300,13 +295,13 @@ async function updateCommand(): Promise<{
}

if (result.success) {
info(`Updated release "${result.release?.name}" to stage "${result.release?.stageName}"`);
info(
`Updated release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)}) to stage ${result.release?.stageName}`,
);
} else {
throw new Error("Failed to update release");
}

info("Finished");

return result.release
? {
release: {
Expand Down Expand Up @@ -575,8 +570,7 @@ async function main() {
result = await updateCommand();
break;
default:
error(`Unknown command: ${command}`);
error("Available commands: sync, complete, update");
error(`Unknown command "${command}" (available: sync, complete, update)`);
process.exit(1);
}

Expand Down
43 changes: 31 additions & 12 deletions src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ export enum LogLevel {
Verbose = 2,
}

type LevelName = "error" | "warn" | "info" | "verbose";

let currentLevel: LogLevel = LogLevel.Default;
let useStderr = false;
let jsonMode = false;

export function setLogLevel(level: LogLevel) {
currentLevel = level;
Expand All @@ -19,32 +22,48 @@ export function setStderr(value: boolean) {
useStderr = value;
}

function write(message: string) {
export function setJsonMode(value: boolean) {
jsonMode = value;
}

function formatLine(level: LevelName, message: string): string {
if (jsonMode) return JSON.stringify({ level, msg: message });
const inGitHubActions = process.env.GITHUB_ACTIONS === "true";
if (level === "error") {
return inGitHubActions ? `::error::${message}` : message;
}
if (level === "warn") {
return inGitHubActions ? `::warning::${message}` : `warning: ${message}`;
}
return message;
}

function write(level: LevelName, message: string) {
if (process.env.NODE_ENV === "test") return;
if (useStderr) {
process.stderr.write(`=> ${message}\n`);
const line = formatLine(level, message);
if (useStderr || level === "error") {
process.stderr.write(`${line}\n`);
} else {
console.log(`=> ${message}`);
console.log(line);
}
}

/** Always printed to stderr, no prefix. */
/** Always printed to stderr. */
export function error(message: string) {
if (process.env.NODE_ENV === "test") return;
process.stderr.write(`${message}\n`);
write("error", message);
}

/** Printed at Default level and above. */
/** Warnings print at all levels, including under --quiet. */
export function warn(message: string) {
if (currentLevel >= LogLevel.Default) write(message);
write("warn", message);
}

/** Printed at Default level and above. Replaces the old `log()`. */
/** Printed at Default level and above. */
export function info(message: string) {
if (currentLevel >= LogLevel.Default) write(message);
if (currentLevel >= LogLevel.Default) write("info", message);
}

/** Printed at Verbose level and above. */
export function verbose(message: string) {
if (currentLevel >= LogLevel.Verbose) write(message);
if (currentLevel >= LogLevel.Verbose) write("verbose", message);
}
Loading