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
8 changes: 0 additions & 8 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3831,14 +3831,6 @@
"summary": "Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., \u0022[Inference API] Title\u0022 becomes \u0022Title\u0022)",
"defaultValue": "false"
},
{
"role": "flag",
"name": "warn-on-type-mismatch",
"type": "boolean",
"required": false,
"summary": "Optional: Warn when the type inferred from release notes section headers doesn\u0027t match the type derived from PR labels. Defaults to true",
"defaultValue": "false"
},
{
"role": "flag",
"name": "log-level",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,8 @@ Cancel ctx
_logger,
ctx,
input.ProfileReport,
_releaseService
_releaseService,
_commitRangeService
);

if (filterResult == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ public class ChangelogRemoveService(
ILoggerFactory logFactory,
IChangelogFileSystem fileSystem,
IConfigurationContext? configurationContext = null,
IGitHubReleaseService? releaseService = null
IGitHubReleaseService? releaseService = null,
IGitHubCommitRangeService? commitRangeService = null
) : IService
{
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogRemoveService>();
private readonly IChangelogFileSystem _fileSystem = fileSystem;
private readonly IGitHubReleaseService _releaseService = releaseService ?? new GitHubReleaseService(logFactory);
private readonly IGitHubCommitRangeService _commitRangeService = commitRangeService ?? new GitHubCommitRangeService(logFactory);
private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null
? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem)
: null;
Expand Down Expand Up @@ -109,7 +111,8 @@ public async Task<bool> RemoveChangelogs(IDiagnosticsCollector collector, Change
_logger,
ctx,
input.ProfileReport,
_releaseService
_releaseService,
_commitRangeService
);

if (filterResult == null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ public static partial class ProfileFilterResolver
ILogger? logger,
Cancel ctx,
string? profileReport = null,
IGitHubReleaseService? releaseService = null
IGitHubReleaseService? releaseService = null,
IGitHubCommitRangeService? commitRangeService = null
)
{
if (config?.Bundle?.Profiles == null || !config.Bundle.Profiles.TryGetValue(profileName, out var profile))
Expand Down Expand Up @@ -115,6 +116,7 @@ public static partial class ProfileFilterResolver
profile,
config,
releaseService,
commitRangeService,
logger,
ctx
);
Expand Down Expand Up @@ -446,6 +448,7 @@ internal static bool TryParseProfileProducts(
BundleProfile profile,
ChangelogConfiguration? config,
IGitHubReleaseService? releaseService,
IGitHubCommitRangeService? commitRangeService,
ILogger? logger,
Cancel ctx
)
Expand Down Expand Up @@ -481,6 +484,12 @@ Cancel ctx
return null;
}

if (commitRangeService == null)
Comment thread
Mpdreamz marked this conversation as resolved.
{
collector.EmitError(string.Empty, $"Profile '{profileName}': a commit-range service is required for 'source: github_release'.");
return null;
}

// Resolve repo and owner: profile-level overrides bundle-level defaults
#pragma warning disable CS0618
var repo = profile.Repo ?? config?.Bundle?.Repo;
Expand Down Expand Up @@ -512,30 +521,55 @@ Cancel ctx

logger?.LogInformation("Fetched release {Tag} from {Owner}/{Repo}", release.TagName, owner, repo);

var parsed = ReleaseNoteParser.Parse(release.Body);
var previousTag = await releaseService.FetchPreviousTagAsync(owner, repo, release.TagName, ctx);
if (previousTag == null)
{
collector.EmitError(
string.Empty,
$"Profile '{profileName}': GitHub could not determine the previous release before '{release.TagName}' in {owner}/{repo}. Cannot derive PR list from commit range."
);
return null;
}

logger?.LogInformation(
"Detected release note format: {Format}, found {Count} PR references",
parsed.Format,
parsed.PrReferences.Count
"Resolving PRs via commit range {PrevTag}..{Tag} for {Owner}/{Repo}",
previousTag,
release.TagName,
owner,
repo
);

if (parsed.PrReferences.Count == 0)
var resolution = await commitRangeService.ResolvePullRequestsAsync(
collector,
new CommitRangeArguments { Owner = owner, Repo = repo, StartRef = previousTag, EndRef = release.TagName },
ctx
);
if (resolution == null)
{
collector.EmitError(
string.Empty,
$"Profile '{profileName}': failed to resolve PR list from commit range {previousTag}..{release.TagName}."
);
return null;
}

if (resolution.PullRequests.Count == 0)
{
collector.EmitWarning(
string.Empty,
$"Profile '{profileName}': no PR references found in release '{release.TagName}'. The bundle will be empty."
$"Profile '{profileName}': no PRs found in commit range {previousTag}..{release.TagName}. The bundle will be empty."
);
return null;
}

var prUrls = parsed.PrReferences.Select(pr => $"https://github.com/{owner}/{repo}/pull/{pr.PrNumber}").ToArray();
var prUrls = resolution.PullRequests.Select(pr => pr.Url).ToArray();

var version = ChangelogTextUtilities.ExtractBaseVersion(release.TagName);
// Infer lifecycle from the raw tag before base-version extraction so that pre-release suffixes
// (e.g. "-preview.1", "-beta.1") are preserved for {lifecycle} substitution in output_products/output.
var lifecycle = VersionLifecycleInference.InferLifecycle(release.TagName);
logger?.LogInformation(
"Resolved {Count} PR URLs from release {Tag} (version: {Version}, lifecycle: {Lifecycle})",
"Resolved {Count} PR(s) from commit range for release {Tag} (version: {Version}, lifecycle: {Lifecycle})",
prUrls.Length,
release.TagName,
version,
Expand Down
55 changes: 55 additions & 0 deletions src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,47 @@ public async Task<IReadOnlyList<GitHubReleaseInfo>> FetchReleasesAsync(
}
}

/// <inheritdoc />
public async Task<string?> FetchPreviousTagAsync(string owner, string repo, string currentTag, CancellationToken ctx = default)
{
try
{
var url = $"https://api.github.com/repos/{owner}/{repo}/releases/generate-notes";
var body = JsonSerializer.Serialize(
new GenerateNotesRequest { TagName = currentTag },
GitHubReleaseJsonContext.Default.GenerateNotesRequest
);
_logger.LogDebug("Generating release notes to resolve previous tag: POST {ApiUrl}", url);

using var response = await _transport.PostAsync(url, body, ctx);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug(
"generate-notes returned {StatusCode} for {Owner}/{Repo}@{Tag}",
response.StatusCode,
owner,
repo,
currentTag
);
return null;
}

var jsonContent = await response.Content.ReadAsStringAsync(ctx);
var data = JsonSerializer.Deserialize(jsonContent, GitHubReleaseJsonContext.Default.GenerateNotesResponse);
return data?.PreviousTagName;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "HTTP error calling generate-notes for {Owner}/{Repo}@{Tag}", owner, repo, currentTag);
return null;
}
catch (TaskCanceledException)
{
_logger.LogWarning("Request timeout calling generate-notes for {Owner}/{Repo}@{Tag}", owner, repo, currentTag);
return null;
}
}

private async Task<GitHubReleaseInfo?> FetchReleaseFromUrl(string url, CancellationToken ctx)
{
_logger.LogDebug("Fetching release info from: {ApiUrl}", url);
Expand Down Expand Up @@ -177,6 +218,18 @@ private static GitHubReleaseInfo ToReleaseInfo(GitHubReleaseResponse releaseData
: []
};

private sealed class GenerateNotesRequest
{
[JsonPropertyName("tag_name")]
public required string TagName { get; set; }
}

private sealed class GenerateNotesResponse
{
[JsonPropertyName("previous_tag_name")]
public string? PreviousTagName { get; set; }
}

private sealed class GitHubReleaseAssetResponse
{
[JsonPropertyName("name")]
Expand Down Expand Up @@ -215,5 +268,7 @@ private sealed class GitHubReleaseResponse

[JsonSerializable(typeof(GitHubReleaseResponse))]
[JsonSerializable(typeof(GitHubReleaseResponse[]))]
[JsonSerializable(typeof(GenerateNotesRequest))]
[JsonSerializable(typeof(GenerateNotesResponse))]
private sealed partial class GitHubReleaseJsonContext : JsonSerializerContext;
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ public interface IGitHubReleaseService
/// <returns>The releases, or an empty list if the fetch fails</returns>
Task<IReadOnlyList<GitHubReleaseInfo>> FetchReleasesAsync(string owner, string repo, int count, CancellationToken ctx = default);

/// <summary>
/// Asks GitHub to generate release notes for <paramref name="currentTag"/> and returns
/// the tag name of the previous release as determined by GitHub's own algorithm.
/// Uses <c>POST /repos/{owner}/{repo}/releases/generate-notes</c> — a read-only
/// preview that produces no side effects.
/// </summary>
/// <returns>The previous tag name, or null if the call fails or GitHub cannot determine one.</returns>
Task<string?> FetchPreviousTagAsync(string owner, string repo, string currentTag, CancellationToken ctx = default);

/// <summary>
/// Downloads a release asset's content as text
/// </summary>
Expand Down
Loading
Loading