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
47 changes: 47 additions & 0 deletions src/Web/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Devlooped.Sponsors;

public static class EnumerableExtensions
{
/// <summary>
/// Batches the source sequence into chunks of the specified size.
/// </summary>
/// <typeparam name="T">The type of the elements in the source sequence.</typeparam>
/// <param name="source">The source sequence to batch.</param>
/// <param name="size">The maximum size of each batch. Must be greater than 0.</param>
/// <returns>An IEnumerable of IEnumerables, each representing a batch of elements.</returns>
/// <exception cref="ArgumentNullException">Thrown if source is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown if size is less than or equal to 0.</exception>
public static IEnumerable<IEnumerable<T>> Batch<T>(this IEnumerable<T> source, int size)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (size <= 0)
throw new ArgumentOutOfRangeException(nameof(size), "Size must be greater than 0.");

return BatchIterator(source, size);
}

static IEnumerable<IEnumerable<T>> BatchIterator<T>(IEnumerable<T> source, int size)
{
var batch = new List<T>(size);
foreach (var item in source)
{
batch.Add(item);
if (batch.Count == size)
{
yield return batch;
batch = new List<T>(size);
}
}
if (batch.Count > 0)
{
yield return batch;
}
}
}
11 changes: 7 additions & 4 deletions src/Web/Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
<OutputType>Exe</OutputType>
<Product>SponsorLink</Product>
</PropertyGroup>
<ItemGroup>
<Content Include="local.settings.json" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Devlooped.CredentialManager" Version="2.6.1" />
<PackageReference Include="DotNetConfig.Configuration" Version="1.2.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.2" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.2" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
Expand All @@ -28,7 +31,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="CliWrap" Version="3.8.2" />
<PackageReference Include="Octokit.Webhooks.AzureFunctions" Version="2.4.1" />
<PackageReference Include="Octokit.Webhooks.AzureFunctions" Version="3.2.1" />
<PackageReference Include="ThisAssembly.Constants" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="YamlPeek" Version="1.0.0" />
</ItemGroup>
Expand Down
115 changes: 112 additions & 3 deletions src/Web/Webhook.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Octokit;
using Octokit.Webhooks;
using Octokit.Webhooks.Events;
using Octokit.Webhooks.Events.IssueComment;
using Octokit.Webhooks.Events.Issues;
using Octokit.Webhooks.Events.Release;
using Octokit.Webhooks.Events.Sponsorship;
using Octokit.Webhooks.Models;

Expand All @@ -16,7 +18,7 @@ public partial class Webhook(SponsorsManager manager, SponsoredIssues issues, IC
{
static readonly ActivitySource tracer = ActivityTracer.Source;

protected override async Task ProcessSponsorshipWebhookAsync(WebhookHeaders headers, SponsorshipEvent payload, SponsorshipAction action)
protected override async ValueTask ProcessSponsorshipWebhookAsync(WebhookHeaders headers, SponsorshipEvent payload, SponsorshipAction action, CancellationToken cancellationToken = default)
{
using var activity = tracer.StartActivity("Sponsorship");
activity?.AddEvent(new ActivityEvent($"{activity?.OperationName}.{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(action)}"));
Expand All @@ -33,7 +35,112 @@ await issues.AddSponsorship(
await base.ProcessSponsorshipWebhookAsync(headers, payload, action);
}

protected override async Task ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action)
protected override async ValueTask ProcessReleaseWebhookAsync(WebhookHeaders headers, ReleaseEvent payload, ReleaseAction action, CancellationToken cancellationToken = default)
{
if (action != ReleaseAction.Deleted)
{
// fetch sponsors markdown from https://github.com/devlooped/sponsors/raw/refs/heads/main/sponsors.md
// lookup for <!-- sponsors --> and <!-- /sponsors --> markers
// replace that section in the release body with the markdown

try
{
using var activity = tracer.StartActivity("Release");
activity?.AddEvent(new ActivityEvent($"{activity?.OperationName}.{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(action)}"));

var body = payload.Release.Body ?? string.Empty;
if (body.Contains("<!-- nosponsors -->"))
return;

const string startMarker = "<!-- sponsors -->";
const string endMarker = "<!-- /sponsors -->";

// Get sponsors markdown
using var http = new HttpClient();
var sponsorsMarkdown = await http.GetStringAsync("https://github.com/devlooped/sponsors/raw/refs/heads/main/sponsors.md", cancellationToken);
if (string.IsNullOrWhiteSpace(sponsorsMarkdown))
return;

var logins = LoginExpr().Matches(sponsorsMarkdown)
.Select(x => x.Groups["login"].Value)
.Where(x => !string.IsNullOrEmpty(x))
.Select(x => "@" + x)
.Distinct();

var newSection =
$"""
<!-- avoid this section by leaving a nosponsors tag -->
## Sponsors

The following sponsors made this release possible: {string.Join(", ", logins)}.

Thanks 💜
""";

// NOTE: no need to append the images since GH already does this by showing them in a
// Contributors generated section.
// {string.Concat(sponsorsMarkdown.ReplaceLineEndings().Replace(Environment.NewLine, ""))}

// In case we want to split into rows of X max icons instead...
//+ string.Join(
// Environment.NewLine,
// sponsorsMarkdown.ReplaceLineEndings()
// .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
// .Batch(15)
// .Select(batch => string.Concat(batch.Select(s => s.Trim())).Trim()));

var before = body;
var after = "";

var start = body.IndexOf(startMarker, StringComparison.Ordinal);
if (start > 0)
{
// Build the updated body preserving the markers
before = body[..start];
var end = body.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
if (end > 0)
after = body[(end + endMarker.Length)..];
}

var newBody =
$"""
{before.Trim()}

{startMarker}

{newSection.Trim()}

{endMarker}

{after.Trim()}
""";

if (!string.Equals(newBody, body, StringComparison.Ordinal))
{
// Update release body via GitHub API
var repo = payload.Repository;
if (repo is not null)
{
var update = new ReleaseUpdate
{
Body = newBody
};

await github.Repository.Release.Edit(repo.Owner.Login, repo.Name, payload.Release.Id, update);
}
}
}
catch (Exception e)
{
logger.LogError(e, e.Message);
throw;
}
}

await base.ProcessReleaseWebhookAsync(headers, payload, action);
}

protected override async ValueTask ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action, CancellationToken cancellationToken = default)
{
if (await issues.UpdateBacked(github, payload.Repository?.Id, (int)payload.Issue.Number) is null)
// It was not an issue or it was not found.
Expand Down Expand Up @@ -80,7 +187,7 @@ await notifier.PostAsync(new PushoverMessage
}
}

protected override async Task ProcessIssuesWebhookAsync(WebhookHeaders headers, IssuesEvent payload, IssuesAction action)
protected override async ValueTask ProcessIssuesWebhookAsync(WebhookHeaders headers, IssuesEvent payload, IssuesAction action, CancellationToken cancellationToken = default)
{
if (await issues.UpdateBacked(github, payload.Repository?.Id, (int)payload.Issue.Number) is not { } amount)
// It was not an issue or it was not found.
Expand Down Expand Up @@ -197,4 +304,6 @@ static bool IsBot(Octokit.Webhooks.Models.User? user) =>
user?.Name?.EndsWith("bot]") == true ||
user?.Name?.EndsWith("-bot") == true;

[GeneratedRegex(@"\(https://github.com/(?<login>[^\)]+)\)")]
private static partial Regex LoginExpr();
}
Loading