Skip to content

Commit 35f950f

Browse files
committed
Mention active sponsors in releases
GitHub will auto-generate a Contributors section with the profile pics of all mentioned accounts. This will trigger the notification to all sponsors, which should be enough visibility. See for example https://github.com/devlooped/dotnet-retest/releases/tag/v1.0.0 Also allow skipping the sponsors section entirely by adding `<!-- nosponsors -->` anywhere in the body.
1 parent f4e935e commit 35f950f

File tree

3 files changed

+166
-7
lines changed

3 files changed

+166
-7
lines changed

src/Web/EnumerableExtensions.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace Devlooped.Sponsors;
8+
9+
public static class EnumerableExtensions
10+
{
11+
/// <summary>
12+
/// Batches the source sequence into chunks of the specified size.
13+
/// </summary>
14+
/// <typeparam name="T">The type of the elements in the source sequence.</typeparam>
15+
/// <param name="source">The source sequence to batch.</param>
16+
/// <param name="size">The maximum size of each batch. Must be greater than 0.</param>
17+
/// <returns>An IEnumerable of IEnumerables, each representing a batch of elements.</returns>
18+
/// <exception cref="ArgumentNullException">Thrown if source is null.</exception>
19+
/// <exception cref="ArgumentOutOfRangeException">Thrown if size is less than or equal to 0.</exception>
20+
public static IEnumerable<IEnumerable<T>> Batch<T>(this IEnumerable<T> source, int size)
21+
{
22+
if (source == null)
23+
throw new ArgumentNullException(nameof(source));
24+
if (size <= 0)
25+
throw new ArgumentOutOfRangeException(nameof(size), "Size must be greater than 0.");
26+
27+
return BatchIterator(source, size);
28+
}
29+
30+
static IEnumerable<IEnumerable<T>> BatchIterator<T>(IEnumerable<T> source, int size)
31+
{
32+
var batch = new List<T>(size);
33+
foreach (var item in source)
34+
{
35+
batch.Add(item);
36+
if (batch.Count == size)
37+
{
38+
yield return batch;
39+
batch = new List<T>(size);
40+
}
41+
}
42+
if (batch.Count > 0)
43+
{
44+
yield return batch;
45+
}
46+
}
47+
}

src/Web/Web.csproj

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@
55
<OutputType>Exe</OutputType>
66
<Product>SponsorLink</Product>
77
</PropertyGroup>
8+
<ItemGroup>
9+
<Content Include="local.settings.json" />
10+
</ItemGroup>
811
<ItemGroup>
912
<FrameworkReference Include="Microsoft.AspNetCore.App" />
1013
<PackageReference Include="Azure.Identity" Version="1.13.2" />
1114
<PackageReference Include="Devlooped.CredentialManager" Version="2.6.1" />
1215
<PackageReference Include="DotNetConfig.Configuration" Version="1.2.0" />
1316
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
14-
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
17+
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.1.0" />
1518
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
16-
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.1" />
19+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.2" />
1720
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
18-
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.2" />
21+
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
1922
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
2023
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" />
2124
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
@@ -28,7 +31,7 @@
2831
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
2932
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="2.0.14" PrivateAssets="all" />
3033
<PackageReference Include="CliWrap" Version="3.8.2" />
31-
<PackageReference Include="Octokit.Webhooks.AzureFunctions" Version="2.4.1" />
34+
<PackageReference Include="Octokit.Webhooks.AzureFunctions" Version="3.2.1" />
3235
<PackageReference Include="ThisAssembly.Constants" Version="2.0.14" PrivateAssets="all" />
3336
<PackageReference Include="YamlPeek" Version="1.0.0" />
3437
</ItemGroup>

src/Web/Webhook.cs

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using System.Diagnostics;
22
using System.Globalization;
3+
using System.Text.RegularExpressions;
34
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.Logging;
56
using Octokit;
67
using Octokit.Webhooks;
78
using Octokit.Webhooks.Events;
89
using Octokit.Webhooks.Events.IssueComment;
910
using Octokit.Webhooks.Events.Issues;
11+
using Octokit.Webhooks.Events.Release;
1012
using Octokit.Webhooks.Events.Sponsorship;
1113
using Octokit.Webhooks.Models;
1214

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

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

36-
protected override async Task ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action)
38+
protected override async ValueTask ProcessReleaseWebhookAsync(WebhookHeaders headers, ReleaseEvent payload, ReleaseAction action, CancellationToken cancellationToken = default)
39+
{
40+
if (action != ReleaseAction.Deleted)
41+
{
42+
// fetch sponsors markdown from https://github.com/devlooped/sponsors/raw/refs/heads/main/sponsors.md
43+
// lookup for <!-- sponsors --> and <!-- /sponsors --> markers
44+
// replace that section in the release body with the markdown
45+
46+
try
47+
{
48+
using var activity = tracer.StartActivity("Release");
49+
activity?.AddEvent(new ActivityEvent($"{activity?.OperationName}.{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(action)}"));
50+
51+
var body = payload.Release.Body ?? string.Empty;
52+
if (body.Contains("<!-- nosponsors -->"))
53+
return;
54+
55+
const string startMarker = "<!-- sponsors -->";
56+
const string endMarker = "<!-- /sponsors -->";
57+
58+
// Get sponsors markdown
59+
using var http = new HttpClient();
60+
var sponsorsMarkdown = await http.GetStringAsync("https://github.com/devlooped/sponsors/raw/refs/heads/main/sponsors.md", cancellationToken);
61+
if (string.IsNullOrWhiteSpace(sponsorsMarkdown))
62+
return;
63+
64+
var logins = LoginExpr().Matches(sponsorsMarkdown)
65+
.Select(x => x.Groups["login"].Value)
66+
.Where(x => !string.IsNullOrEmpty(x))
67+
.Select(x => "@" + x)
68+
.Distinct();
69+
70+
var newSection =
71+
$"""
72+
<!-- avoid this section by leaving a nosponsors tag -->
73+
## Sponsors
74+
75+
The following sponsors made this release possible: {string.Join(", ", logins)}.
76+
77+
Thanks 💜
78+
""";
79+
80+
// NOTE: no need to append the images since GH already does this by showing them in a
81+
// Contributors generated section.
82+
// {string.Concat(sponsorsMarkdown.ReplaceLineEndings().Replace(Environment.NewLine, ""))}
83+
84+
// In case we want to split into rows of X max icons instead...
85+
//+ string.Join(
86+
// Environment.NewLine,
87+
// sponsorsMarkdown.ReplaceLineEndings()
88+
// .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
89+
// .Batch(15)
90+
// .Select(batch => string.Concat(batch.Select(s => s.Trim())).Trim()));
91+
92+
var before = body;
93+
var after = "";
94+
95+
var start = body.IndexOf(startMarker, StringComparison.Ordinal);
96+
if (start > 0)
97+
{
98+
// Build the updated body preserving the markers
99+
before = body[..start];
100+
var end = body.IndexOf(endMarker, start + startMarker.Length, StringComparison.Ordinal);
101+
if (end > 0)
102+
after = body[(end + endMarker.Length)..];
103+
}
104+
105+
var newBody =
106+
$"""
107+
{before.Trim()}
108+
109+
{startMarker}
110+
111+
{newSection.Trim()}
112+
113+
{endMarker}
114+
115+
{after.Trim()}
116+
""";
117+
118+
if (!string.Equals(newBody, body, StringComparison.Ordinal))
119+
{
120+
// Update release body via GitHub API
121+
var repo = payload.Repository;
122+
if (repo is not null)
123+
{
124+
var update = new ReleaseUpdate
125+
{
126+
Body = newBody
127+
};
128+
129+
await github.Repository.Release.Edit(repo.Owner.Login, repo.Name, payload.Release.Id, update);
130+
}
131+
}
132+
}
133+
catch (Exception e)
134+
{
135+
logger.LogError(e, e.Message);
136+
throw;
137+
}
138+
}
139+
140+
await base.ProcessReleaseWebhookAsync(headers, payload, action);
141+
}
142+
143+
protected override async ValueTask ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action, CancellationToken cancellationToken = default)
37144
{
38145
if (await issues.UpdateBacked(github, payload.Repository?.Id, (int)payload.Issue.Number) is null)
39146
// It was not an issue or it was not found.
@@ -80,7 +187,7 @@ await notifier.PostAsync(new PushoverMessage
80187
}
81188
}
82189

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

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

0 commit comments

Comments
 (0)