diff --git a/README.md b/README.md index 8eed3c3..f0d23f0 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Creates a release or adds issues to the current release. This is the default com linear-release sync # Specify custom name and version -linear-release sync --name="v1.2.0" --version="1.2.0" +linear-release sync --name="Release 1.2.0" --version="1.2.0" ``` ### `complete` @@ -131,6 +131,18 @@ linear-release update --stage="in review" --version="1.2.0" | `--version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, 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 | + +### JSON Output + +Use `--json` to get structured output for scripting. + +```bash +linear-release sync --json +# => {"release":{"id":"...","name":"Release 1.2.0","version":"1.2.0","url":"https://linear.app/..."}} +``` + +When no release is created (e.g. no commits found), `--json` outputs `{"release":null}`. ### Path Filtering diff --git a/src/index.ts b/src/index.ts index 6b4e62c..8aabdb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { PullRequestSource, RepoInfo, } from "./types"; -import { log } from "./log"; +import { log, setStderr } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; @@ -42,6 +42,7 @@ Options: --version= Release version identifier --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) + --json Output result as JSON -v, --version Show version number -h, --help Show this help message @@ -88,6 +89,12 @@ const releaseVersion: string | null = versionArg ? versionArg.replace("--version const stageArg = process.argv.find((arg) => arg.startsWith("--stage=")); const stageName: string | null = stageArg ? stageArg.replace("--stage=", "").trim() : null; +// Parse --json flag for structured JSON output +const jsonOutput = process.argv.includes("--json"); +if (jsonOutput) { + setStderr(true); +} + const logEnvironmentSummary = () => { log("Using access key authentication"); @@ -212,7 +219,9 @@ function scanCommits( }; } -async function syncCommand() { +async function syncCommand(): Promise<{ + release: { id: string; name: string; version?: string; url?: string }; +} | null> { logEnvironmentSummary(); // Fetch pipeline settings from API @@ -279,7 +288,7 @@ async function syncCommand() { ? `matching ${JSON.stringify(effectiveIncludePaths)}` : "in the computed range"; log(`No commits found ${reason}. Skipping release creation.`); - return; + return null; } const { issueIdentifiers, prNumbers, debugSink } = scanCommits(commits, effectiveIncludePaths); @@ -302,9 +311,13 @@ async function syncCommand() { ); log("Finished"); + + return { release: { id: release.id, name: release.name, version: release.version, url: release.url } }; } -async function completeCommand() { +async function completeCommand(): Promise<{ + release: { id: string; name: string; version?: string; url?: string }; +} | null> { logEnvironmentSummary(); const currentCommit = await getCurrentGitInfo(); @@ -321,9 +334,22 @@ async function completeCommand() { } log("Finished"); + + return result.release + ? { + release: { + id: result.release.id, + name: result.release.name, + version: result.release.version, + url: result.release.url, + }, + } + : null; } -async function updateCommand() { +async function updateCommand(): Promise<{ + release: { id: string; name: string; version?: string; url?: string }; +} | null> { logEnvironmentSummary(); if (!stageName) { @@ -348,6 +374,17 @@ async function updateCommand() { } log("Finished"); + + return result.release + ? { + release: { + id: result.release.id, + name: result.release.name, + version: result.release.version, + url: result.release.url, + }, + } + : null; } async function getLatestRelease(): Promise { @@ -428,6 +465,7 @@ async function syncRelease( release { id name + url version commitSha createdAt @@ -461,7 +499,7 @@ async function syncRelease( async function completeRelease(options: { version?: string | null; commitSha?: string | null; -}): Promise<{ success: boolean; release: { name: string } | null }> { +}): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null }> { const { version, commitSha } = options; const response = (await linearClient.client.rawRequest( @@ -470,7 +508,10 @@ async function completeRelease(options: { releaseCompleteByAccessKey(input: $input) { success release { + id name + version + url } } } @@ -488,7 +529,7 @@ async function completeRelease(options: { async function updateReleaseByPipeline(options: { stage?: string; version?: string | null }): Promise<{ success: boolean; - release: { name: string; stageName: string } | null; + release: { id: string; name: string; version?: string; url?: string; stageName: string } | null; }> { const { stage, version } = options; const versionInput = version ? `, version: "${version}"` : ""; @@ -504,7 +545,10 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri releaseUpdateByPipelineByAccessKey(input: { ${inputParts} }) { success release { + id name + version + url stage { name } @@ -519,7 +563,10 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri success: result.success, release: result.release ? { + id: result.release.id, name: result.release.name, + version: result.release.version, + url: result.release.url, stageName: result.release.stage?.name ?? "(unknown)", } : null, @@ -527,21 +574,27 @@ async function updateReleaseByPipeline(options: { stage?: string; version?: stri } async function main() { + let result: { release: { id: string; name: string; version?: string; url?: string } } | null = null; + switch (command) { case "sync": - await syncCommand(); + result = await syncCommand(); break; case "complete": - await completeCommand(); + result = await completeCommand(); break; case "update": - await updateCommand(); + result = await updateCommand(); break; default: console.error(`Unknown command: ${command}`); console.error("Available commands: sync, complete, update"); process.exit(1); } + + if (jsonOutput) { + console.log(JSON.stringify(result ?? { release: null })); + } } main().catch((error) => { diff --git a/src/log.ts b/src/log.ts index eeac651..3cb594c 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,5 +1,15 @@ +let useStderr = false; + +export function setStderr(value: boolean) { + useStderr = value; +} + export function log(message: string) { if (process.env.NODE_ENV !== "test") { - console.log(`=> ${message}`); + if (useStderr) { + process.stderr.write(`=> ${message}\n`); + } else { + console.log(`=> ${message}`); + } } } diff --git a/src/types.ts b/src/types.ts index b27b499..f9139f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,7 +37,10 @@ export type AccessKeyCompleteReleaseResponse = { releaseCompleteByAccessKey: { success: boolean; release: { + id: string; name: string; + version?: string; + url?: string; } | null; }; }; @@ -48,7 +51,10 @@ export type AccessKeyUpdateByPipelineResponse = { releaseUpdateByPipelineByAccessKey: { success: boolean; release: { + id: string; name: string; + version?: string; + url?: string; stage: { name: string; } | null;