11using System . Diagnostics ;
22using System . Globalization ;
3+ using System . Text . RegularExpressions ;
34using Microsoft . Extensions . Configuration ;
45using Microsoft . Extensions . Logging ;
56using Octokit ;
67using Octokit . Webhooks ;
78using Octokit . Webhooks . Events ;
89using Octokit . Webhooks . Events . IssueComment ;
910using Octokit . Webhooks . Events . Issues ;
11+ using Octokit . Webhooks . Events . Release ;
1012using Octokit . Webhooks . Events . Sponsorship ;
1113using 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