From cdf76c2ee30736d157700774eb46b8e178e525f2 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Thu, 21 May 2026 14:53:53 +0100 Subject: [PATCH] Allow passing document and release-notes flags --- README.md | 51 +++++++++--- src/args.test.ts | 201 +++++++++++++++++++++++++++++++++++++++++++---- src/args.ts | 112 ++++++++++++++++++++++++-- src/index.ts | 121 +++++++++++++++++++++++++--- 4 files changed, 441 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 552728e..a8eb2e7 100644 --- a/README.md +++ b/README.md @@ -149,18 +149,22 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### CLI Options -| Option | Commands | Description | -| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | -| `--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 | -| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | -| `--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. | -| `--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) | +| Option | Commands | Description | +| ---------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | +| `--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 | +| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | +| `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | +| `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | +| `--release-notes` | `sync`, `complete`, `update` | Set the release notes for this release. Inline markdown. If combined with `--release-notes-file`, the last flag wins. | +| `--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. | +| `--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) | ### Command Targeting @@ -231,6 +235,29 @@ linear-release complete --release-version="1.2.0" \ Each value is either an absolute URL or `Label=URL`. Both `--link "Label=..."` and `--link="Label=..."` are accepted. `http(s)` is the typical scheme; the server rejects unsafe ones like `javascript:` or `data:`. +### Documents and release notes + +Attach release notes and supporting documents to a release. Each release has at most one set of release notes (last `--release-notes` / `--release-notes-file` wins). Documents are repeatable and keyed by title — re-syncing with the same title updates content in place. + +```bash +# Release notes from a generated changelog +linear-release sync --release-notes-file ./CHANGELOG.md + +# Plus extra documents (deploy log, runbook, etc.) +linear-release sync \ + --release-notes-file ./CHANGELOG.md \ + --document-file "Deploy log=./deploy.log" \ + --document-file "Runbook=./runbook.md" + +# Stdin works on both flags — useful when piping from another command +git log v1.0.0..HEAD --format="- %s" | linear-release sync --release-notes-file - + +# Inline (single-line content only — see "Multi-line content" below) +linear-release sync --document "Deploy log=Deployed to production at $(date -u +%FT%TZ)" +``` + +> **Multi-line content**: use `--document-file` / `--release-notes-file`. Inline `\n` inside `"…"` is passed verbatim by the shell — same gotcha as `gh release create --notes`, `git commit -m`, and `helm --set`. For inline multi-line, use a real newline in the quotes or [`$'…\n…'`](https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html). + ## How It Works 1. **Fetches the latest release** from your Linear pipeline to determine the commit range diff --git a/src/args.test.ts b/src/args.test.ts index 996ebb6..094c192 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getCLIWarnings, parseCLIArgs } from "./args"; +import { parseCLIArgs } from "./args"; import { LogLevel } from "./log"; describe("parseCLIArgs", () => { @@ -155,23 +155,192 @@ describe("parseCLIArgs", () => { expect(result.links).toEqual([{ url: "https://ci.example.com/run/123" }]); }); - it("throws on unknown flags (strict mode)", () => { - expect(() => parseCLIArgs(["--unknown-flag"])).toThrow(); - }); - - it("returns no warning when --name is used with update", () => { - const result = parseCLIArgs(["update", "--name", "Release 1.2.0"]); - expect(getCLIWarnings(result)).toEqual([]); + describe("--document / --document-file", () => { + it("parses --document with Title=content", () => { + const result = parseCLIArgs(["sync", "--document", "Changelog=# v1.0.0\n\n- first release"]); + expect(result.documents).toEqual([ + { title: "Changelog", source: { kind: "inline", content: "# v1.0.0\n\n- first release" } }, + ]); + }); + + it("parses multiple repeatable --document values", () => { + const result = parseCLIArgs([ + "sync", + "--document", + "Changelog=# v1.0.0", + "--document=Deploy=Deployed to production.", + ]); + expect(result.documents).toEqual([ + { title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } }, + { title: "Deploy", source: { kind: "inline", content: "Deployed to production." } }, + ]); + }); + + it("preserves whitespace and equals signs in --document content", () => { + const result = parseCLIArgs(["sync", "--document", "Args=key1=value1\n key2 = value2"]); + expect(result.documents).toEqual([ + { title: "Args", source: { kind: "inline", content: "key1=value1\n key2 = value2" } }, + ]); + }); + + it("parses --document-file with Title=path", () => { + const result = parseCLIArgs(["sync", "--document-file", "Changelog=./CHANGELOG.md"]); + expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]); + }); + + it("trims title and path on --document-file", () => { + const result = parseCLIArgs(["sync", "--document-file", " Changelog = ./CHANGELOG.md "]); + expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]); + }); + + it("infers title from filename when --document-file is given a bare path", () => { + const result = parseCLIArgs(["sync", "--document-file", "./CHANGELOG.md"]); + expect(result.documents).toEqual([{ title: "CHANGELOG", source: { kind: "file", path: "./CHANGELOG.md" } }]); + }); + + it("infers title from basename when --document-file path has nested directories", () => { + const result = parseCLIArgs(["sync", "--document-file", "./docs/deploy-log.md"]); + expect(result.documents).toEqual([ + { title: "deploy-log", source: { kind: "file", path: "./docs/deploy-log.md" } }, + ]); + }); + + it("infers title from bare path with no extension", () => { + const result = parseCLIArgs(["sync", "--document-file", "./NOTES"]); + expect(result.documents).toEqual([{ title: "NOTES", source: { kind: "file", path: "./NOTES" } }]); + }); + + it("strips only the final extension when inferring title", () => { + const result = parseCLIArgs(["sync", "--document-file", "./release.notes.md"]); + expect(result.documents).toEqual([ + { title: "release.notes", source: { kind: "file", path: "./release.notes.md" } }, + ]); + }); + + it("throws when --document-file is bare '-' (stdin needs an explicit title)", () => { + expect(() => parseCLIArgs(["sync", "--document-file", "-"])).toThrow("Title=-"); + }); + + it("combines --document and --document-file", () => { + const result = parseCLIArgs([ + "sync", + "--document", + "Changelog=# v1.0.0", + "--document-file", + "Deploy log=./deploy.md", + ]); + expect(result.documents).toEqual([ + { title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } }, + { title: "Deploy log", source: { kind: "file", path: "./deploy.md" } }, + ]); + }); + + it("throws on --document without =", () => { + expect(() => parseCLIArgs(["sync", "--document", "no-separator"])).toThrow( + 'Invalid --document value: "no-separator"', + ); + }); + + it("throws on --document with empty title", () => { + expect(() => parseCLIArgs(["sync", "--document", "=content"])).toThrow("Document title must not be empty"); + }); + + it("throws on --document with empty value", () => { + expect(() => parseCLIArgs(["sync", "--document", "Title="])).toThrow("Document value must not be empty"); + }); + + it("throws on --document-file with empty path", () => { + expect(() => parseCLIArgs(["sync", "--document-file", "Title= "])).toThrow(); + }); + }); + + describe("--release-notes / --release-notes-file", () => { + it("parses inline --release-notes", () => { + const result = parseCLIArgs(["sync", "--release-notes", "## v1.0.0\n\nFirst release."]); + expect(result.releaseNotes).toEqual({ + source: { kind: "inline", content: "## v1.0.0\n\nFirst release." }, + }); + }); + + it("parses --release-notes-file", () => { + const result = parseCLIArgs(["sync", "--release-notes-file", "./notes.md"]); + expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } }); + }); + + it("last-wins across multiple --release-notes occurrences", () => { + const result = parseCLIArgs([ + "sync", + "--release-notes", + "first", + "--release-notes", + "second", + "--release-notes-file", + "./notes.md", + ]); + expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } }); + }); + + it("throws on empty --release-notes-file path", () => { + expect(() => parseCLIArgs(["sync", "--release-notes-file", " "])).toThrow(); + }); + + it("leaves releaseNotes undefined when no flag is passed", () => { + const result = parseCLIArgs(["sync"]); + expect(result.releaseNotes).toBeUndefined(); + }); + + it("preserves argv order across --release-notes-file then --release-notes", () => { + // Regression: previously the parser grouped values by flag name, so a later inline note + // could be overridden by an earlier file note. Last on the command line should always win. + const result = parseCLIArgs([ + "sync", + "--release-notes-file", + "./generated.md", + "--release-notes", + "manual override", + ]); + expect(result.releaseNotes).toEqual({ source: { kind: "inline", content: "manual override" } }); + }); + + it("preserves argv order across --release-notes then --release-notes-file", () => { + const result = parseCLIArgs(["sync", "--release-notes", "manual", "--release-notes-file", "./final.md"]); + expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./final.md" } }); + }); + }); + + describe("argv order across --document / --document-file", () => { + it("preserves argv order so same-title last-wins works across flag types", () => { + // The API upserts documents by title with later entries winning. The CLI must therefore send + // documents in the order the user wrote them on the command line, not bucketed by flag type. + const result = parseCLIArgs([ + "sync", + "--document-file", + "Changelog=./from-file.md", + "--document", + "Changelog=inline override", + ]); + expect(result.documents).toEqual([ + { title: "Changelog", source: { kind: "file", path: "./from-file.md" } }, + { title: "Changelog", source: { kind: "inline", content: "inline override" } }, + ]); + }); + + it("interleaves inline and file documents in argv order", () => { + const result = parseCLIArgs([ + "sync", + "--document", + "A=inline-a", + "--document-file", + "B=./b.md", + "--document", + "C=inline-c", + ]); + expect(result.documents.map((d) => d.title)).toEqual(["A", "B", "C"]); + }); }); - it("returns no warning when --name is used with complete", () => { - const result = parseCLIArgs(["complete", "--name", "Release 1.2.0"]); - expect(getCLIWarnings(result)).toEqual([]); - }); - - it("returns no warning when --name is used with sync", () => { - const result = parseCLIArgs(["sync", "--name", "Release 1.2.0"]); - expect(getCLIWarnings(result)).toEqual([]); + it("throws on unknown flags (strict mode)", () => { + expect(() => parseCLIArgs(["--unknown-flag"])).toThrow(); }); it("defaults --timeout to 60 seconds", () => { diff --git a/src/args.ts b/src/args.ts index cc91adc..1121931 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,3 +1,4 @@ +import { basename, extname } from "node:path"; import { parseArgs } from "node:util"; import { LogLevel } from "./log"; @@ -6,6 +7,18 @@ export type ReleaseLink = { url: string; }; +/** Where the markdown body for a document or release notes comes from. */ +export type ReleaseContentSource = { kind: "inline"; content: string } | { kind: "file"; path: string }; + +export type ReleaseDocumentSpec = { + title: string; + source: ReleaseContentSource; +}; + +export type ReleaseNoteSpec = { + source: ReleaseContentSource; +}; + export type ParsedCLIArgs = { command: string; releaseName?: string; @@ -14,6 +27,8 @@ export type ParsedCLIArgs = { baseRef?: string; includePaths: string[]; links: ReleaseLink[]; + documents: ReleaseDocumentSpec[]; + releaseNotes?: ReleaseNoteSpec; jsonOutput: boolean; timeoutSeconds: number; logLevel: LogLevel; @@ -54,8 +69,60 @@ function parseAbsoluteUrl(value: string): URL | undefined { } } +/** Splits `Title=value` once on `=`. Title is trimmed; value is returned verbatim so markdown whitespace survives. */ +function splitTitleAndValue(raw: string, flag: string): { title: string; value: string } { + const separatorIndex = raw.indexOf("="); + if (separatorIndex === -1) { + throw new Error(`Invalid ${flag} value: "${raw}". Expected "Title=".`); + } + const title = raw.slice(0, separatorIndex).trim(); + const value = raw.slice(separatorIndex + 1); + if (!title) { + throw new Error(`Invalid ${flag} value: "${raw}". Document title must not be empty.`); + } + if (!value) { + throw new Error(`Invalid ${flag} value: "${raw}". Document value must not be empty.`); + } + return { title, value }; +} + +function parseReleaseDocumentInline(raw: string): ReleaseDocumentSpec { + const { title, value } = splitTitleAndValue(raw, "--document"); + return { title, source: { kind: "inline", content: value } }; +} + +function parseReleaseDocumentFile(raw: string): ReleaseDocumentSpec { + // Two accepted shapes, matching `kubectl --from-file=[key=]source`: + // --document-file Title=./path.md (explicit title) + // --document-file ./path.md (title inferred from basename, sans extension) + if (raw.includes("=")) { + const { title, value } = splitTitleAndValue(raw, "--document-file"); + const path = value.trim(); + if (!path) { + throw new Error(`Invalid --document-file value: "${raw}". Path must not be empty.`); + } + return { title, source: { kind: "file", path } }; + } + const path = raw.trim(); + if (!path) { + throw new Error(`Invalid --document-file value: "${raw}". Path must not be empty.`); + } + if (path === "-") { + throw new Error( + `Invalid --document-file value: "-". A title is required when reading from stdin; use --document-file Title=-`, + ); + } + const title = basename(path, extname(path)).trim(); + if (!title) { + throw new Error( + `Invalid --document-file value: "${raw}". Could not infer title from path; use --document-file Title=${path}`, + ); + } + return { title, source: { kind: "file", path } }; +} + export function parseCLIArgs(argv: string[]): ParsedCLIArgs { - const { values, positionals } = parseArgs({ + const { values, positionals, tokens } = parseArgs({ args: argv, options: { name: { type: "string" }, @@ -64,6 +131,10 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "base-ref": { type: "string" }, "include-paths": { type: "string" }, link: { type: "string", multiple: true }, + document: { type: "string", multiple: true }, + "document-file": { type: "string", multiple: true }, + "release-notes": { type: "string", multiple: true }, + "release-notes-file": { type: "string", multiple: true }, json: { type: "boolean", default: false }, timeout: { type: "string" }, quiet: { type: "boolean", default: false }, @@ -71,6 +142,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { }, allowPositionals: true, strict: true, + tokens: true, }); const DEFAULT_TIMEOUT_SECONDS = 60; @@ -94,6 +166,38 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { const command = positionals[0] || "sync"; const links = (values.link ?? []).map(parseReleaseLink); + // Walk tokens in argv order so cross-flag last-wins and same-title overrides work correctly + // (parseArgs's `values` map groups by flag name and loses cross-flag ordering — see + // https://github.com/cli/cli/issues/595 for prior art on why argv order matters here). + const documents: ReleaseDocumentSpec[] = []; + const noteSpecs: ReleaseNoteSpec[] = []; + for (const token of tokens) { + if (token.kind !== "option" || token.value === undefined) continue; + switch (token.name) { + case "document": + documents.push(parseReleaseDocumentInline(token.value)); + break; + case "document-file": + documents.push(parseReleaseDocumentFile(token.value)); + break; + case "release-notes": + if (!token.value) { + throw new Error('Invalid --release-notes value: "". Release notes content must not be empty.'); + } + noteSpecs.push({ source: { kind: "inline", content: token.value } }); + break; + case "release-notes-file": { + const path = token.value.trim(); + if (!path) { + throw new Error(`Invalid --release-notes-file value: "${token.value}". Path must not be empty.`); + } + noteSpecs.push({ source: { kind: "file", path } }); + break; + } + } + } + const releaseNotes = noteSpecs.length > 0 ? noteSpecs[noteSpecs.length - 1] : undefined; + return { command, releaseName: values.name, @@ -107,12 +211,10 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { .filter((p) => p.length > 0) : [], links, + documents, + releaseNotes, jsonOutput: values.json ?? false, timeoutSeconds, logLevel, }; } - -export function getCLIWarnings(_args: ParsedCLIArgs): string[] { - return []; -} diff --git a/src/index.ts b/src/index.ts index 0598b74..e7f7826 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { LinearClient, LinearClientOptions } from "@linear/sdk"; import { assertGitAvailable, @@ -21,7 +22,7 @@ import { IssueReference, RepoInfo, } from "./types"; -import { getCLIWarnings, parseCLIArgs, ReleaseLink } from "./args"; +import { parseCLIArgs, ReleaseContentSource, ReleaseDocumentSpec, ReleaseLink, ReleaseNoteSpec } from "./args"; import { error, info, setJsonMode, setLogLevel, setStderr, verbose, warn } from "./log"; import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; @@ -52,6 +53,10 @@ Options: --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --link Add a link to the targeted release (repeatable) + --document Attach a document to the release (repeatable, Title required) + --document-file <[Title=]path> Attach a document from a file (title inferred from basename if omitted; "-" for stdin requires Title=-; repeatable) + --release-notes Set the release notes covering this release (last-wins) + --release-notes-file Set release notes from a file ("-" for stdin; last-wins) --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) @@ -71,6 +76,9 @@ Examples: linear-release sync --include-paths="apps/web/**,packages/**" linear-release sync --link "https://ci.example.com/run/123" linear-release sync --link "Pipeline=https://ci.example.com/run/123" + linear-release sync --document-file "Changelog=./CHANGELOG.md" + linear-release sync --document-file ./CHANGELOG.md + linear-release sync --release-notes-file ./release-notes.md linear-release sync --base-ref= --include-paths="apps/web/**" `); process.exit(0); @@ -99,11 +107,63 @@ const { baseRef, includePaths, links, + documents: documentSpecs, + releaseNotes: releaseNotesSpec, jsonOutput, timeoutSeconds, logLevel, } = parsedArgs; -const cliWarnings = getCLIWarnings(parsedArgs); + +type ReleaseDocument = { title: string; content: string }; +type ReleaseNotes = { content: string; title?: string }; + +let stdinContent: string | undefined; +function readStdinOnce(flag: string): string { + if (stdinContent === undefined) { + stdinContent = readFileSync(0, "utf8"); + } else { + throw new Error( + `${flag} cannot consume stdin: another flag already read from stdin. Use "-" with at most one --document-file or --release-notes-file.`, + ); + } + return stdinContent; +} + +function resolveContent(source: ReleaseContentSource, flag: string): string { + if (source.kind === "inline") { + return source.content; + } + if (source.path === "-") { + return readStdinOnce(flag); + } + try { + return readFileSync(source.path, "utf8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read ${flag} file "${source.path}": ${message}`); + } +} + +function resolveDocuments(specs: ReleaseDocumentSpec[]): ReleaseDocument[] { + const flag = "--document-file"; + return specs.map((spec) => ({ title: spec.title, content: resolveContent(spec.source, flag) })); +} + +function resolveReleaseNotes(spec: ReleaseNoteSpec | undefined): ReleaseNotes | undefined { + if (!spec) return undefined; + return { content: resolveContent(spec.source, "--release-notes-file") }; +} + +let documents: ReleaseDocument[]; +let releaseNotes: ReleaseNotes | undefined; +try { + documents = resolveDocuments(documentSpecs); + releaseNotes = resolveReleaseNotes(releaseNotesSpec); +} catch (err) { + const message = err instanceof Error ? err.message : String(err); + error(`${message} (run linear-release --help for usage)`); + process.exit(1); +} setLogLevel(logLevel); if (jsonOutput) { setStderr(true); @@ -131,6 +191,14 @@ function formatLinkSummary(linksToFormat: ReleaseLink[]): string { return linksToFormat.length > 0 ? `, links [${linksToFormat.map(formatLinkForLog).join(", ")}]` : ""; } +function formatDocumentsSummary(docs: ReleaseDocument[]): string { + return docs.length > 0 ? `, documents [${docs.map((d) => d.title).join(", ")}]` : ""; +} + +function formatReleaseNotesSummary(notes: ReleaseNotes | undefined): string { + return notes ? `, release notes (${notes.content.length} chars)` : ""; +} + const logEnvironmentSummary = () => { info(`linear-release v${CLI_VERSION}`); if (releaseName) { @@ -139,9 +207,6 @@ const logEnvironmentSummary = () => { if (releaseVersion) { info(`Using custom release version: ${releaseVersion}`); } - for (const w of cliWarnings) { - warn(w); - } }; const getDevApiUrl = () => { @@ -280,13 +345,24 @@ async function syncCommand(): Promise<{ const repoInfo = getRepoInfo(); - const release = await syncRelease(issueReferences, revertedIssueReferences, prNumbers, repoInfo, debugSink, links); + const release = await syncRelease( + issueReferences, + revertedIssueReferences, + prNumbers, + repoInfo, + debugSink, + links, + 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)}`); + info( + `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, + ); if (scanBase.kind === "base-ref") { info(`Stored release baseline: ${(release.commitSha ?? currentCommit.commit).slice(0, 7)}`); } @@ -314,10 +390,12 @@ async function completeCommand(): Promise<{ version: releaseVersion, commitSha: commitSha ?? undefined, links, + documents, + releaseNotes, }); if (result.success) { info( - `Completed release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)})${formatLinkSummary(links)}`, + `Completed release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)})${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, ); } else { throw new Error("Failed to complete release"); @@ -351,6 +429,8 @@ async function updateCommand(): Promise<{ version: releaseVersion, name: releaseName, links, + documents, + releaseNotes, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; @@ -359,7 +439,7 @@ async function updateCommand(): Promise<{ if (result.success) { info( - `Updated release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)}) to stage ${result.release?.stageName}${formatLinkSummary(links)}`, + `Updated release ${result.release?.name ?? "(unknown)"} (${formatVersion(result.release)}) to stage ${result.release?.stageName}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, ); } else { throw new Error("Failed to update release"); @@ -469,6 +549,8 @@ async function syncRelease( repoInfo: RepoInfo | null, debugSink: DebugSink, releaseLinks: ReleaseLink[], + releaseDocuments: ReleaseDocument[], + releaseNotesValue: ReleaseNotes | undefined, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -505,6 +587,8 @@ async function syncRelease( issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, links: releaseLinks.length > 0 ? releaseLinks : undefined, + documents: releaseDocuments.length > 0 ? releaseDocuments : undefined, + releaseNotes: releaseNotesValue, pullRequestReferences: prNumbers.map((number) => ({ repositoryOwner: owner, repositoryName: name, @@ -535,11 +619,20 @@ async function completeRelease(options: { version?: string; commitSha?: string; links: ReleaseLink[]; + documents: ReleaseDocument[]; + releaseNotes?: ReleaseNotes; }): Promise<{ success: boolean; release: { id: string; name: string; version?: string; url?: string } | null; }> { - const { name, version, commitSha, links: releaseLinks } = options; + const { + name, + version, + commitSha, + links: releaseLinks, + documents: releaseDocuments, + releaseNotes: notesValue, + } = options; const response = await apiRequest( ` @@ -561,6 +654,8 @@ async function completeRelease(options: { version, commitSha, links: releaseLinks.length > 0 ? releaseLinks : undefined, + documents: releaseDocuments.length > 0 ? releaseDocuments : undefined, + releaseNotes: notesValue, }, }, ); @@ -573,6 +668,8 @@ async function updateReleaseByPipeline(options: { version?: string; name?: string; links: ReleaseLink[]; + documents: ReleaseDocument[]; + releaseNotes?: ReleaseNotes; }): Promise<{ success: boolean; release: { @@ -583,7 +680,7 @@ async function updateReleaseByPipeline(options: { stageName: string; } | null; }> { - const { stage, version, name, links: releaseLinks } = options; + const { stage, version, name, links: releaseLinks, documents: releaseDocuments, releaseNotes: notesValue } = options; const response = await apiRequest( ` mutation releaseUpdateByPipelineByAccessKey($input: ReleaseUpdateByPipelineInputBase!) { @@ -607,6 +704,8 @@ async function updateReleaseByPipeline(options: { version, name, links: releaseLinks.length > 0 ? releaseLinks : undefined, + documents: releaseDocuments.length > 0 ? releaseDocuments : undefined, + releaseNotes: notesValue, }, }, );