Skip to content
Open
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
230 changes: 230 additions & 0 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3927,6 +3927,236 @@
}
]
},
{
"path": [
"changelog"
],
"name": "note",
"summary": "Create a note-*.yml changelog fragment for items that have no associated PR.",
"notes": "Use this command for release notes that are not tied to a specific pull request \u2014 for example, a\nknown-issue entry or a cross-cutting change that spans many PRs. Every product listed must have a\nconcrete target (not a wildcard); --prs and --issues are still accepted as\noptional citations but do not affect the output filename.",
"usage": "docs-builder changelog note [options]",
"examples": [],
"parameters": [
{
"role": "flag",
"name": "name",
"type": "string",
"required": false,
"summary": "Explicit slug for the note filename. Defaults to a slug derived from the title."
},
{
"role": "flag",
"name": "products",
"type": "string",
"required": false,
"summary": "Products in format \u0022product target lifecycle, ...\u0022 (for example, \u0022elasticsearch 9.2.0 ga\u0022). Target is required for all products."
},
{
"role": "flag",
"name": "action",
"type": "string",
"required": false,
"summary": "Optional action text."
},
{
"role": "flag",
"name": "areas",
"type": "array",
"required": false,
"summary": "Optional area tags.",
"repeatable": true,
"elementType": "string"
},
{
"role": "flag",
"name": "concise",
"type": "boolean",
"required": false,
"summary": "Omit schema reference comments from the generated YAML.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "config",
"type": "string",
"required": false,
"summary": "Path to the changelog.yml configuration file.",
"validations": [
{
"kind": "rejectSymbolicLinks"
},
{
"kind": "existing"
},
{
"kind": "fileExtensions",
"values": [
"yml",
"yaml"
]
}
]
},
{
"role": "flag",
"name": "description",
"type": "string",
"required": false,
"summary": "Entry description."
},
{
"role": "flag",
"name": "no-extract-release-notes",
"type": "boolean",
"required": false,
"summary": "Skip extracting release note text from PR/issue descriptions.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "no-extract-issues",
"type": "boolean",
"required": false,
"summary": "Skip extracting linked issues/PRs from PR/issue body.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "feature-id",
"type": "string",
"required": false,
"summary": "Optional feature ID."
},
{
"role": "flag",
"name": "highlight",
"type": "boolean",
"required": false,
"summary": "Mark the entry as a highlight.",
"defaultValue": "default"
},
{
"role": "flag",
"name": "impact",
"type": "string",
"required": false,
"summary": "Optional impact text."
},
{
"role": "flag",
"name": "issues",
"type": "array",
"required": false,
"summary": "Optional issue URLs (cited but not used as anchor).",
"repeatable": true,
"elementType": "string"
},
{
"role": "flag",
"name": "owner",
"type": "string",
"required": false,
"summary": "GitHub owner. Falls back to bundle.owner or \u0022elastic\u0022."
},
{
"role": "flag",
"name": "output",
"type": "string",
"required": false,
"summary": "Output directory."
},
{
"role": "flag",
"name": "prs",
"type": "array",
"required": false,
"summary": "Optional PR URLs (cited but not used as anchor).",
"repeatable": true,
"elementType": "string"
},
{
"role": "flag",
"name": "repo",
"type": "string",
"required": false,
"summary": "GitHub repository name."
},
{
"role": "flag",
"name": "strip-title-prefix",
"type": "boolean",
"required": false,
"summary": "Strip a repo-name prefix from the title.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "strict-fetch",
"type": "boolean",
"required": false,
"summary": "Treat GitHub fetch failures as errors.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "subtype",
"type": "string",
"required": false,
"summary": "Entry subtype."
},
{
"role": "flag",
"name": "title",
"type": "string",
"required": false,
"summary": "Entry title (required)."
},
{
"role": "flag",
"name": "type",
"type": "string",
"required": false,
"summary": "Entry type (required)."
},
{
"role": "flag",
"name": "log-level",
"shortName": "l",
"type": "enum",
"required": false,
"summary": "Minimum log level. Default: information",
"enumValues": [
"trace",
"debug",
"information",
"warning",
"error",
"critical",
"none"
]
},
{
"role": "flag",
"name": "config-source",
"shortName": "c",
"type": "enum",
"required": false,
"summary": "Override the configuration source: local, remote",
"enumValues": [
"local",
"remote",
"embedded"
]
},
{
"role": "flag",
"name": "skip-private-repositories",
"type": "boolean",
"required": false,
"summary": "Skip cloning private repositories"
}
]
},
{
"path": [
"changelog"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public record CreateChangelogArguments
/// unauthorized GITHUB_TOKEN would otherwise silently produce unfiltered, title-less changelogs.
Comment thread
Mpdreamz marked this conversation as resolved.
/// </summary>
public bool StrictFetch { get; init; }

public bool IsNote { get; init; }
public string? NoteName { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -146,6 +149,72 @@ public async Task<bool> CreateChangelog(IDiagnosticsCollector collector, CreateC
}
}

public async Task<bool> CreateNote(IDiagnosticsCollector collector, CreateChangelogArguments input, Cancel ctx)
{
try
{
var cliDescription = input.Description;
input = EnrichFromCI(input);
Comment thread
Mpdreamz marked this conversation as resolved.

var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx);
if (config == null)
{
collector.EmitError(string.Empty, "Failed to load changelog configuration");
return false;
}

input = ApplyConfigDefaults(input, config);
Comment thread
Mpdreamz marked this conversation as resolved.

// Mirror CreateChangelog: discard CI-injected description when extraction is disabled
if (input.ExtractionDisabled
&& string.IsNullOrWhiteSpace(cliDescription)
&& !string.IsNullOrWhiteSpace(input.Description))
{
_logger.LogInformation("Clearing CI-provided description because release note extraction is disabled");
input = input with { Description = null };
}

// Validate PR citation format (same rule as `add`: numeric refs require --owner/--repo)
if (input.Prs is { Length: > 1 })
{
if (!_validator.ValidateMultiplePrFormat(collector, input.Prs, input.Owner, input.Repo))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateNote only runs ValidateMultiplePrFormat when more than one PR is passed, but that validator only fails when all entries are numeric. A mixed input like --prs 123 --prs https://github.com/elastic/elasticsearch/pull/999 can therefore pass without repo context, leaving 123 as an ambiguous citation.

Given the intended rule here (numeric refs require repo context), this should validate each PR entry (or fail when any entry is numeric and repo context is missing), not only the all-numeric case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1b42111 — changed ValidateMultiplePrFormat from All() to Any() so a single bare numeric entry in a mixed numeric+URL list is rejected when --owner/--repo are absent. New test: CreateNote_MixedNumericAndUrlPrWithoutOwnerRepo_ReturnsError.

return false;
}
else if (!_validator.ValidatePrFormat(collector, input.Prs?.FirstOrDefault(), input.Owner, input.Repo))
return false;

// Validate issue citation format
if (input.Issues is { Length: > 1 })
{
if (!_validator.ValidateMultipleIssueFormat(collector, input.Issues, input.Owner, input.Repo))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gap for issues: in the multi-value branch, ValidateMultipleIssueFormat only rejects when all entries are numeric. A mixed input like --issues 456 --issues https://github.com/elastic/elasticsearch/issues/789 can pass without repo context and persist an ambiguous bare numeric issue.

Please apply per-entry validation (or reject when any numeric value is present without owner/repo context), matching the single-value validation rule.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1b42111 — same Any() fix applied to ValidateMultipleIssueFormat. New test: CreateNote_MixedNumericAndUrlIssueWithoutOwnerRepo_ReturnsError.

return false;
}
else if (!_validator.ValidateIssueFormat(collector, input.Issues?.FirstOrDefault(), input.Owner, input.Repo))
return false;

if (!_validator.ValidateRequiredFields(collector, input, prFetchFailed: false))
Comment thread
Mpdreamz marked this conversation as resolved.
return false;

if (!_validator.ValidateNoteProducts(collector, input))
return false;

if (!_validator.ValidateAgainstConfiguration(collector, input, config))
return false;

return await _fileWriter.WriteNoteAsync(input, config, ctx);
}
catch (IOException ioEx)
{
collector.EmitError(string.Empty, $"IO error creating note: {ioEx.Message}", ioEx);
return false;
}
catch (UnauthorizedAccessException uaEx)
{
collector.EmitError(string.Empty, $"Access denied creating note: {uaEx.Message}", uaEx);
return false;
}
}

internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) =>
// Filename strategy is always Pr now; UsePrNumber is kept for backward compat but is effectively always true.
input with
Expand Down
53 changes: 53 additions & 0 deletions src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,59 @@ public async Task<bool> WriteChangelogAsync(
return true;
}

public async Task<bool> WriteNoteAsync(
CreateChangelogArguments input,
ChangelogConfiguration config,
Cancel ctx)
{
var changelogData = BuildChangelogData(input);
var yamlContent = input.Concise
? GenerateConciseYaml(changelogData)
: GenerateYaml(changelogData, config, titleMissing: false, typeMissing: false);

var outputDir = input.Output ?? fileSystem.Directory.GetCurrentDirectory();
if (!fileSystem.Directory.Exists(outputDir))
_ = fileSystem.Directory.CreateDirectory(outputDir);

var filename = GenerateNoteFilename(input.NoteName, input.Title);
var filePath = fileSystem.Path.Join(outputDir, filename);

var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent);
await fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx);
logger.LogInformation("Created note fragment: {FilePath}", filePath);
return true;
}

private static string GenerateNoteFilename(string? noteName, string? title)
{
var source = !string.IsNullOrWhiteSpace(noteName) ? noteName : title;
if (string.IsNullOrWhiteSpace(source))
return "note-untitled.yml";
var slug = Slugify(source);
return string.IsNullOrEmpty(slug) ? "note-untitled.yml" : $"note-{slug}.yml";
}

private static string Slugify(string text)
{
var sb = new StringBuilder(text.Length);
var prevWasHyphen = false;
foreach (var ch in text.ToLowerInvariant())
{
if (char.IsLetterOrDigit(ch))
{
_ = sb.Append(ch);
prevWasHyphen = false;
}
else if (!prevWasHyphen && sb.Length > 0)
{
_ = sb.Append('-');
prevWasHyphen = true;
}
}
var result = sb.ToString().TrimEnd('-');
return result.Length > 60 ? result[..60].TrimEnd('-') : result;
}

/// <summary>Maximum filename length before extension to avoid filesystem path-too-long errors.</summary>
private const int MaxFilenameLength = 200;

Expand Down
Loading
Loading