From cd81c74e13201547f5e2e4e1ab9b95ea01e647e8 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 14 Aug 2024 19:00:02 -0300 Subject: [PATCH] Add GitHub webhook for auto-labeling of sponsor issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-labeling works by configuring a webhook (at repo or organization level). The optional `GitHub:Secret` configration can be used to secure the webhook callback, but it's not required (although recommended). The endpoint is `github/webhooks` and should be set to receive `application/json` content type. Currently, the webhook callback only processes sponsorship changes (to refresh the cached list of sponsors), issues and issue comments. Issue actions result in auto-labeling. Labels for sponsors of certain tiers can be configured via yaml metadata in the tier description, like: ``` ☕ You want to buy me a Nespresso capsule, and everyone should be allowed to do that 🤗 ``` Tiers inherit metadata from lower tiers so you don't have to repeat them. Also optionally introduce pushover-based notifications (for issue and issue comment actions), if the following secrets are configured: - `PushOver:Token`: the API key/token - `PushOver:Key`: the user or delivery group key See https://pushover.net/api for more info on that. --- src/Core/GraphQueries.cs | 118 ++++++++++++++-- src/Core/HttpGraphQueryClient.cs | 6 +- src/Core/JsonOptions.cs | 2 + src/Core/Pushover.cs | 159 +++++++++++++++++++++ src/Core/Records.cs | 7 +- src/Core/SponsorsManager.cs | 216 ++++++++++++++++++++++------- src/Tests/Attributes.cs | 21 +++ src/Tests/GraphQueriesTests.cs | 13 ++ src/Tests/Misc.cs | 17 +++ src/Tests/SponsorManagerTests.cs | 53 ++++++- src/Tests/Tests.csproj | 2 +- src/Web/ConfigurationExtensions.cs | 24 ++++ src/Web/Program.cs | 185 ++++++++++++------------ src/Web/SponsorLink.Legacy.cs | 7 + src/Web/SponsorLink.cs | 124 ++++++++--------- src/Web/Web.csproj | 2 + src/Web/Webhook.cs | 123 ++++++++++++++++ 17 files changed, 858 insertions(+), 221 deletions(-) create mode 100644 src/Core/Pushover.cs create mode 100644 src/Web/ConfigurationExtensions.cs create mode 100644 src/Web/Webhook.cs diff --git a/src/Core/GraphQueries.cs b/src/Core/GraphQueries.cs index 0ea99dbd..1a36f40d 100644 --- a/src/Core/GraphQueries.cs +++ b/src/Core/GraphQueries.cs @@ -1,4 +1,5 @@ -using Scriban; +using System.Security.Principal; +using Scriban; namespace Devlooped.Sponsors; @@ -24,6 +25,21 @@ public static partial class GraphQueries IsLegacy = true }; + /// + /// Emails API is not available in GraphQL (yet?). So we must use a legacy query via REST API. + /// + /// + /// See https://github.com/orgs/community/discussions/24389#discussioncomment-3243994 + /// + public static GraphQuery Emails(string user) => new( + $"/users/{user}", + """ + [.email] + """) + { + IsLegacy = true + }; + /// /// Returns a tuple of (login, type) for the viewer. /// @@ -418,11 +434,11 @@ ... on User { /// /// Returns the unique repository owners of all repositories the user has contributed - /// commits to. + /// commits to within the last year. /// /// - /// If a single user contributes to more than 100 repositories, we'd have a problem - /// and would need to implement pagination. + /// See https://github.com/orgs/community/discussions/24350#discussioncomment-4195303. + /// Contributions only includes last year's. /// public static GraphQuery UserContributions(string user, int pageSize = 100) => new( """ @@ -430,7 +446,6 @@ ... on User { user(login: $login) { repositoriesContributedTo(first: $count, includeUserRepositories: true, contributionTypes: [COMMIT], after: $endCursor) { nodes { - nameWithOwner, owner { login } @@ -509,6 +524,29 @@ ... on User { } }; + public static GraphQuery FindAccount(string account) => new( + """ + query($login: String!) { + user(login: $login) { + login + type: __typename + } + organization(login: $login) { + login + type: __typename + } + } + """, + """ + .data.user? + .data.organization? + """) + { + Variables = + { + { "login", account } + } + }; + /// /// Tries to get a user account. /// @@ -639,12 +677,13 @@ public static GraphQuery IsSponsoredBy(string sponsorable, params stri sponsorsListing { tiers(first: 100){ nodes { + id, name, description, monthlyPriceInDollars, isOneTime, closestLesserValueTier { - name + id }, } } @@ -654,12 +693,13 @@ public static GraphQuery IsSponsoredBy(string sponsorable, params stri sponsorsListing { tiers(first: 100){ nodes { + id, name, description, monthlyPriceInDollars, isOneTime, closestLesserValueTier { - name + id }, } } @@ -668,7 +708,7 @@ public static GraphQuery IsSponsoredBy(string sponsorable, params stri } """, """ - [(.data.user? + .data.organization?).sponsorsListing.tiers.nodes.[] | { name, description, amount: .monthlyPriceInDollars, oneTime: .isOneTime, previous: .closestLesserValueTier.name }] + [(.data.user? + .data.organization?).sponsorsListing.tiers.nodes.[] | { id, name, description, amount: .monthlyPriceInDollars, oneTime: .isOneTime, previous: .closestLesserValueTier.id }] """) { Variables = @@ -677,6 +717,68 @@ public static GraphQuery IsSponsoredBy(string sponsorable, params stri } }; + /// + /// If the sponsorable is a user, we don't support pagination for now and it will + /// return the maximum limit of 100 entities. + /// + public static GraphQuery Sponsors(string sponsorable) => new( + """ + query($login: String!, $endCursor: String) { + organization (login: $login) { + sponsorshipsAsMaintainer (activeOnly:true, first: 100, after: $endCursor) { + nodes { + sponsorEntity { + ... on Organization { login, __typename } + ... on User { login, __typename } + } + tier { + id, + name, + description, + isCustomAmount, + isOneTime, + monthlyPriceInDollars, + closestLesserValueTier { + id + } + } + } + pageInfo { hasNextPage, endCursor } + } + } + user (login: $login) { + sponsorshipsAsMaintainer (activeOnly:true, first: 100) { + nodes { + sponsorEntity { + ... on Organization { login, __typename } + ... on User { login, __typename } + } + tier { + id, + name, + description, + isCustomAmount, + isOneTime, + monthlyPriceInDollars, + closestLesserValueTier { + id + } + } + } + } + } + } + """, + """ + [(.data.user? + .data.organization?).sponsorshipsAsMaintainer.nodes.[] | { login: .sponsorEntity.login, type: .sponsorEntity.__typename, tier: { id: .tier.id, name: .tier.name, description: .tier.description, amount: .tier.monthlyPriceInDollars, oneTime: .tier.isOneTime, previous: .tier.closestLesserValueTier.id } }] + """) + { + Variables = + { + { "login", sponsorable } + } + }; + /// /// Gets the verified sponsoring organizations for a given sponsorable organization. /// diff --git a/src/Core/HttpGraphQueryClient.cs b/src/Core/HttpGraphQueryClient.cs index e78f65a5..02dc9476 100644 --- a/src/Core/HttpGraphQueryClient.cs +++ b/src/Core/HttpGraphQueryClient.cs @@ -113,7 +113,11 @@ await JQ.ExecuteAsync(raw, query.JQ) : if (typed is IEnumerable array) items.AddRange(array); - info = JsonSerializer.Deserialize(await JQ.ExecuteAsync(raw, ".. | .pageInfo? | values"), JsonOptions.Default); + var pageInfoRaw = await JQ.ExecuteAsync(raw, ".. | .pageInfo? | values"); + if (string.IsNullOrEmpty(pageInfoRaw)) + break; + + info = JsonSerializer.Deserialize(pageInfoRaw, JsonOptions.Default); if (info is null || !info.HasNextPage) break; } diff --git a/src/Core/JsonOptions.cs b/src/Core/JsonOptions.cs index b2349b01..c2d0ca68 100644 --- a/src/Core/JsonOptions.cs +++ b/src/Core/JsonOptions.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -15,6 +16,7 @@ static partial class JsonOptions new() #endif { + //Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, AllowTrailingCommas = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReadCommentHandling = JsonCommentHandling.Skip, diff --git a/src/Core/Pushover.cs b/src/Core/Pushover.cs new file mode 100644 index 00000000..183f3446 --- /dev/null +++ b/src/Core/Pushover.cs @@ -0,0 +1,159 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace Devlooped.Sponsors; + +public interface IPushover +{ + Task PostAsync(PushoverMessage message); +} + +public class PushoverOptions +{ + public string? Token { get; set; } + public string? Key { get; set; } + + public PushoverPriority IssuePriority { get; set; } = PushoverPriority.High; + public PushoverPriority IssueCommentPriority { get; set; } = PushoverPriority.High; +} + + +public enum PushoverPriority +{ + Lowest = -2, + Low = -1, + Normal = 0, + High = 1, + Emergency = 2 +} + +public class PushoverMessage +{ + /// + /// The message's title, otherwise the app's name is used + /// + public string? Title { get; set; } + + /// + /// The message to send. + /// + public string? Message { get; set; } + + /// + /// An image attachment to send with the message. + /// + public string? Attachment { get; set; } + + /// + /// A supplementary URL to show with the message + /// + public string? Url { get; set; } + + /// + /// A title for the supplementary URL, otherwise just the URL is shown + /// + [JsonPropertyName("url_title")] + public string? UrlTitle { get; set; } + + /// + /// The priority of the message + /// + public PushoverPriority Priority { get; set; } = PushoverPriority.Normal; + + /// + /// The name of the sound to use with + /// + public string Sound { get; set; } = "pushover"; + + public PushoverMessage() { } + + public PushoverMessage(string message) => Message = message; +} + +public static class PushoverSounds +{ + /// Pushover (default) + public const string Pushover = "pushover"; + /// Bike + public const string Bike = "bike"; + /// Bugle + public const string Bugle = "bugle"; + /// Cash Register + public const string Cashregister = "cashregister"; + /// Classical + public const string Classical = "classical"; + /// Cosmic + public const string Cosmic = "cosmic"; + /// Falling + public const string Falling = "falling"; + /// Gamelan + public const string Gamelan = "gamelan"; + /// Incoming + public const string Incoming = "incoming"; + /// Intermission + public const string Intermission = "intermission"; + /// Magic + public const string Magic = "magic"; + /// Mechanical + public const string Mechanical = "mechanical"; + /// Piano Bar + public const string Pianobar = "pianobar"; + /// Siren + public const string Siren = "siren"; + /// Space Alarm + public const string Spacealarm = "spacealarm"; + /// Tug Boat + public const string Tugboat = "tugboat"; + /// Alien Alarm (long) + public const string Alien = "alien"; + /// Climb (long) + public const string Climb = "climb"; + /// Persistent (long) + public const string Persistent = "persistent"; + /// Pushover Echo (long) + public const string Echo = "echo"; + /// Up Down (long) + public const string Updown = "updown"; + /// Vibrate Only + public const string Vibrate = "vibrate"; + /// None (silent) + public const string None = "none"; +} + +public class Pushover(IHttpClientFactory factory, IOptions options) : IPushover +{ + static JsonSerializerOptions json = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + //Converters = + //{ + // new JsonStringEnumConverter(allowIntegerValues: false), + //} + }; + + readonly PushoverOptions options = options.Value; + + public async Task PostAsync(PushoverMessage message) + { + if (options.Token == null || options.Key == null) + return; + + using var http = factory.CreateClient(); + + var node = JsonNode.Parse(JsonSerializer.Serialize(message, json)); + Debug.Assert(node != null); + + node["token"] = options.Token; + node["user"] = options.Key; + + var response = await http.PostAsJsonAsync("https://api.pushover.net/1/messages.json", node); + + response.EnsureSuccessStatusCode(); + } +} \ No newline at end of file diff --git a/src/Core/Records.cs b/src/Core/Records.cs index 4399567c..47045fd9 100644 --- a/src/Core/Records.cs +++ b/src/Core/Records.cs @@ -20,7 +20,12 @@ public record Sponsorship(string Sponsorable, [property: Browsable(false)] strin #endif [property: DisplayName("One-time")] bool OneTime); -public record Tier(string Name, string Description, int Amount, bool OneTime) +public record Sponsor(string Login, AccountType Type, Tier Tier) +{ + public SponsorTypes Kind { get; init; } = Type == AccountType.Organization ? SponsorTypes.Organization : SponsorTypes.User; +} + +public record Tier(string Id, string Name, string Description, int Amount, bool OneTime, string? Previous = null) { public Dictionary Meta { get; init; } = []; } diff --git a/src/Core/SponsorsManager.cs b/src/Core/SponsorsManager.cs index da2edd99..c524b02c 100644 --- a/src/Core/SponsorsManager.cs +++ b/src/Core/SponsorsManager.cs @@ -21,19 +21,107 @@ public partial class SponsorsManager( static readonly Serializer serializer = new(); readonly SponsorLinkOptions options = options.Value; + Dictionary? sponsors; + Dictionary? tiers; + + public void RefreshSponsors() => sponsors = null; + + internal async Task> GetSponsorsAsync() + { + if (sponsors is not null) + return sponsors; + + var client = graphFactory.CreateClient("sponsorable"); + var account = await GetSponsorable(cache, client, options); + var tiers = await GetTiersAsync(); + var raw = await client.QueryAsync(GraphQueries.Sponsors(account.Login)); + logger.Assert(raw != null, "Failed to retrieve sponsors for {0}.", account.Login); + + var result = new Dictionary(); + + foreach (var sponsor in raw) + { + if (!tiers.TryGetValue(sponsor.Tier.Id, out var tier)) + result.Add(sponsor.Login, sponsor with { Tier = AddTier(sponsor.Tier, tiers) }); + else + result.Add(sponsor.Login, sponsor with { Tier = tier }); + } + + sponsors = result; + return sponsors; + } + + internal async Task> GetTiersAsync() + { + if (tiers is not null) + return tiers; + + var client = graphFactory.CreateClient("sponsorable"); + var result = new Dictionary(); + + var account = await GetSponsorable(cache, client, options); + var json = await client.QueryAsync(GraphQueries.Tiers(account.Login)); + + // TODO: should be an error? + if (string.IsNullOrEmpty(json) || + JsonSerializer.Deserialize(json, JsonOptions.Default) is not { Length: > 0 } raw) + { + logger.LogWarning("No tiers were found for account {Login}.", account.Login); + return result; + } + + foreach (var item in raw) + { + var tier = new Tier(item.Id, item.Name, item.Description, item.Amount, item.OneTime, item.Previous); + AddTier(tier, result); + } + + tiers = result; + return result; + } + + /// + /// Adds a tier, prior to populating its metadata from all ancestor tiers. + /// + static Tier AddTier(Tier tier, Dictionary tiers) + { + var yaml = YamlRegex().Match(tier.Description)?.Groups["yaml"]?.Value; + if (!string.IsNullOrEmpty(yaml) && + serializer.Deserialize>(yaml) is { } meta) + { + foreach (var entry in meta) + { + tier.Meta.TryAdd(entry.Key, entry.Value); + } + } + + // Walk the tiers to aggregate metadata provided by lower tiers. + // This avoids having to repeat metadata in each tier. + var current = tier; + while (true) + { + if (string.IsNullOrEmpty(current.Previous) || + !tiers.TryGetValue(current.Previous, out var previous)) + break; + + current = previous; + // Don't overwrite existing metadata. + foreach (var entry in current.Meta) + { + tier.Meta.TryAdd(entry.Key, entry.Value); + } + } + + tiers.Add(tier.Id, tier); + return tier; + } + public async Task GetRawManifestAsync() { if (!cache.TryGetValue(JwtCacheKey, out var jwt) || string.IsNullOrEmpty(jwt)) { var client = graphFactory.CreateClient("sponsorable"); - - var account = string.IsNullOrEmpty(options.Account) ? - // default to the authenticated user login - await client.QueryAsync(GraphQueries.ViewerAccount) - ?? throw new ArgumentException("Failed to determine sponsorable user from configured GitHub token.") : - await client.QueryAsync(GraphQueries.FindOrganization(options.Account)) - ?? await client.QueryAsync(GraphQueries.FindUser(options.Account)) - ?? throw new ArgumentException("Failed to determine sponsorable user from configured GitHub token."); + var account = await GetSponsorable(cache, client, options); var url = $"https://github.com/{account.Login}/.github/raw/main/sponsorlink.jwt"; @@ -54,7 +142,7 @@ await client.QueryAsync(GraphQueries.FindOrganization(options.Account)) if (account.Login != manifest.Sponsorable) throw new InvalidOperationException("Manifest sponsorable account does not match configured sponsorable account."); - var cacheExpiration = TimeSpan.TryParse(options.BadgeExpiration, out var expiration) ? expiration : TimeSpan.FromHours(1); + var cacheExpiration = TimeSpan.TryParse(options.ManifestExpiration, out var expiration) ? expiration : TimeSpan.FromHours(1); jwt = cache.Set(JwtCacheKey, jwt, cacheExpiration); manifest = cache.Set(ManifestCacheKey, manifest, cacheExpiration); @@ -245,60 +333,90 @@ await sponsorable.QueryAsync(GraphQueries.SponsoringOrganizationsForUser(account return claims; } - public async Task> GetTiers() + public async Task FindSponsorAsync(string? login) { - if (!cache.TryGetValue>(typeof(List), out var tiers) || tiers is null) - { - var manifest = await GetManifestAsync(); - var client = graphFactory.CreateClient("sponsorable"); - tiers = []; + if (login is null) + return null; + + var graph = graphFactory.CreateClient("sponsorable"); + var account = await graph.QueryAsync(GraphQueries.FindAccount(login)); + var sponsorable = await GetSponsorable(cache, graph, options); + var sponsors = await GetSponsorsAsync(); - var json = await client.QueryAsync(GraphQueries.Tiers(manifest.Sponsorable)); + // This returns direct sponsors. + if (sponsors.TryGetValue(login, out var sponsor)) + return sponsor; - // TODO: should be an error? - if (string.IsNullOrEmpty(json) || - JsonSerializer.Deserialize(json, JsonOptions.Default) is not { Length: > 0 } raw) - return tiers; + var orgs = await graph.QueryAsync(GraphQueries.UserOrganizations(login)); + if (orgs is not null && orgs.Any(x => x.Login == sponsorable.Login)) + { + // This avoids having to check contributions for team members. + return new Sponsor(login, account?.Type ?? AccountType.User, new Tier("team", "Team", "Team", 0, false) + { + Meta = { ["tier"] = "team" } + }) + { + Kind = SponsorTypes.Team, + }; + } - foreach (var item in raw) + // Lookup for indirect sponsors, first via repo contributions. + var contribs = await graph.QueryAsync(GraphQueries.UserContributions(login)); + if (contribs is not null && contribs.Contains(sponsorable.Login)) + { + return new Sponsor(login, account?.Type ?? AccountType.User, new Tier("contrib", "Contributor", "Contributor", 0, false) { - var tier = new Tier(item.Name, YamlRegex().Replace(item.Description, ""), item.Amount, item.OneTime); - var current = item; - while (true) + Meta = { - var yaml = YamlRegex().Match(current.Description)?.Groups["yaml"]?.Value; - if (!string.IsNullOrEmpty(yaml) && - serializer.Deserialize>(yaml) is { } meta) - { - foreach (var entry in meta) - { - // An existing value should not be overwritten - tier.Meta.TryAdd(entry.Key, entry.Value); - } - } + ["tier"] = "contrib", + ["label"] = "sponsor 💚", + ["color"] = "#BFFFD3" + } + }) + { + Kind = SponsorTypes.Contributor, + }; + } - // Walk the tiers to aggregate metadata provided by lower tiers. - // This avoids having to repeat metadata in each tier. - if (string.IsNullOrEmpty(current.Previous) || - raw.FirstOrDefault(x => x.Name == current.Previous) is not { } previous) - break; + if (orgs is null || orgs.Length == 0) + return null; - current = previous; - } - tiers.Add(tier); - } + Sponsor? orgSponsor = default; - tiers = cache.Set(typeof(List), tiers, new MemoryCacheEntryOptions + // Pick highest tier org. + foreach (var org in orgs) + { + if (await FindSponsorAsync(org.Login) is { } found && + (orgSponsor is null || found.Tier.Amount > orgSponsor.Tier.Amount)) { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) - }); + orgSponsor = found; + } } - return tiers; + return orgSponsor; + } + + static async Task GetSponsorable(IMemoryCache cache, IGraphQueryClient client, SponsorLinkOptions options) + { + var key = nameof(SponsorsManager) + ".Sponsorable"; + if (cache.TryGetValue(key, out var value) && value != null) + return value; + + var account = string.IsNullOrEmpty(options.Account) ? + // default to the authenticated user login + await client.QueryAsync(GraphQueries.ViewerAccount) + ?? throw new ArgumentException("Failed to determine sponsorable user from configured GitHub token.") : + await client.QueryAsync(GraphQueries.FindOrganization(options.Account)) + ?? await client.QueryAsync(GraphQueries.FindUser(options.Account)) + ?? throw new ArgumentException("Failed to determine sponsorable account from configured SponsorLink account."); + + cache.Set(key, account); + + return account; } - record RawTier(string Name, string Description, int Amount, bool OneTime, string? Previous); + record RawTier(string Id, string Name, string Description, int Amount, bool OneTime, string? Previous); - [GeneratedRegex("")] + [GeneratedRegex("", RegexOptions.Singleline)] private static partial Regex YamlRegex(); } diff --git a/src/Tests/Attributes.cs b/src/Tests/Attributes.cs index 5c76f705..4cbf2dfd 100644 --- a/src/Tests/Attributes.cs +++ b/src/Tests/Attributes.cs @@ -40,6 +40,27 @@ public CIFactAttribute() } } +public class SecretsTheoryAttribute : TheoryAttribute +{ + public SecretsTheoryAttribute(params string[] secrets) + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + + var missing = new HashSet(); + + foreach (var secret in secrets) + { + if (string.IsNullOrEmpty(configuration[secret])) + missing.Add(secret); + } + + if (missing.Count > 0) + Skip = "Missing user secrets: " + string.Join(',', missing); + } +} + public class LocalTheoryAttribute : TheoryAttribute { public LocalTheoryAttribute() diff --git a/src/Tests/GraphQueriesTests.cs b/src/Tests/GraphQueriesTests.cs index 41eca05b..c1e8942d 100644 --- a/src/Tests/GraphQueriesTests.cs +++ b/src/Tests/GraphQueriesTests.cs @@ -512,6 +512,19 @@ public async Task GetPagedUserOrganizations() Assert.Equal(httpdata, clidata); } + [SecretsFact("SponsorLink:Account", "GitHub:Token")] + public async Task GetAllSponsors() + { + var client = new HttpGraphQueryClient(Services.GetRequiredService(), "GitHub:Token"); + + var sponsors = await client.QueryAsync(GraphQueries.Sponsors(Helpers.Configuration["SponsorLink:Account"]!)); + + Assert.NotNull(sponsors); + Assert.NotEmpty(sponsors); + Assert.All(sponsors, x => Assert.NotNull(x.Login)); + Assert.All(sponsors, x => Assert.NotNull(x.Tier)); + } + void EnsureAuthenticated(string secret = "GitHub:Token") { Assert.True(TryExecute("gh", "auth login --with-token", Configuration[secret]!, out var output)); diff --git a/src/Tests/Misc.cs b/src/Tests/Misc.cs index f42e249a..771c2b19 100644 --- a/src/Tests/Misc.cs +++ b/src/Tests/Misc.cs @@ -79,6 +79,23 @@ public async Task GetPurgeStatus() record PurgeStatus(string status); + [SecretsFact("Pushover:Token", "Pushover:Key")] + public async Task PushMessage() + { + var options = new PushoverOptions(); + Configuration.Bind("Pushover", options); + var pushover = new Pushover(Services.GetRequiredService(), Options.Create(options)); + + await pushover.PostAsync(new PushoverMessage + { + Title = $"🐛 by kzu as Silver sponsor", + Message = "Add optional endpoint that can emit shields endpoint badge data", + Url = "https://github.com/devlooped/SponsorLink/pull/258", + UrlTitle = $"View Issue #{258}", + Priority = PushoverPriority.High + }); + } + public static void SponsorsAscii() { var heart = diff --git a/src/Tests/SponsorManagerTests.cs b/src/Tests/SponsorManagerTests.cs index 0b2603a8..009a84e2 100644 --- a/src/Tests/SponsorManagerTests.cs +++ b/src/Tests/SponsorManagerTests.cs @@ -271,7 +271,7 @@ public async Task GetTiersWithMetadata() services.GetRequiredService(), Mock.Of>()); - var tiers = await manager.GetTiers(); + var tiers = await manager.GetTiersAsync(); // Meta is populated from comments in the sponsor listing description, // which is used to hold a yaml block with metadata. @@ -280,6 +280,55 @@ public async Task GetTiersWithMetadata() Assert.NotNull(tiers); Assert.NotEmpty(tiers); - Assert.Contains(tiers, x => x.Meta.ContainsKey("tier")); + + // No tier should have fewer meta items than any previous one. + // NOTE: one-time tiers do not cascade to monthly tiers (they don't share parent tiers) + + var meta = new HashSet(); + foreach (var tier in tiers.Values.Where(t => t.OneTime)) + { + Assert.True(tier.Meta.Count >= meta.Count, $"Tier {tier.Name} does not have at least {meta.Count} metadata items"); + meta.AddRange(tier.Meta.Keys); + } + + meta.Clear(); + foreach (var tier in tiers.Values.Where(t => !t.OneTime)) + { + Assert.True(tier.Meta.Count >= meta.Count, $"Tier {tier.Name} does not have at least {meta.Count} metadata items"); + meta.AddRange(tier.Meta.Keys); + } + + Assert.All(tiers.Values, x => Assert.Contains("label", x.Meta.Keys)); + } + + [SecretsTheory("GitHub:Sponsorable")] + [InlineData("clarius", "basic", SponsorTypes.Organization)] + [InlineData("torutek-gh", "silver", SponsorTypes.User)] + [InlineData("KirillOsenkov", "silver", SponsorTypes.User)] + [InlineData("victorgarciaaprea", "basic", SponsorTypes.Organization)] + [InlineData("kzu", "team", SponsorTypes.Team)] + [InlineData("stakx", "contrib", SponsorTypes.None)] // since only *recent* (1yr) contributions count + public async Task GetTierForLogin(string login, string tier, SponsorTypes type) + { + var manager = new SponsorsManager( + services.GetRequiredService>(), + httpFactory, + services.GetRequiredService(), + services.GetRequiredService(), + Mock.Of>()); + + var sponsor = await manager.FindSponsorAsync(login); + + if (type == SponsorTypes.None) + { + Assert.Null(sponsor); + return; + } + + Assert.NotNull(sponsor); + + Assert.True(sponsor.Tier.Meta.TryGetValue("tier", out var existing)); + Assert.Equal(tier, existing); + Assert.Equal(type, sponsor.Kind); } } diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 91350c63..d6478e48 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -5,7 +5,7 @@ true Devlooped.Tests - CS0436;CS0435;NU1701 + CS0436;CS0435;NU1701;RS1036;RS2008 false true diff --git a/src/Web/ConfigurationExtensions.cs b/src/Web/ConfigurationExtensions.cs new file mode 100644 index 00000000..518b3759 --- /dev/null +++ b/src/Web/ConfigurationExtensions.cs @@ -0,0 +1,24 @@ +using Azure.Identity; +using Microsoft.Extensions.Configuration; + +namespace Devlooped.Sponsors; + +public static class ConfigurationExtensions +{ + public static IConfigurationBuilder Configure(this IConfigurationBuilder builder) + { + builder.AddUserSecrets("A85AC898-E41C-4D9D-AD9B-52ED748D9901"); + // Optionally, use key vault for secrets instead of plain-text app service configuration + if (Environment.GetEnvironmentVariable("AZURE_KEYVAULT") is string kv) + builder.AddAzureKeyVault(new Uri($"https://{kv}.vault.azure.net/"), new DefaultAzureCredential()); + +#if DEBUG + // Allows using SL config for local development. + // In particular, the telemetry module will inject the local id as if had been received from 'x-telemetry-id' header + // when testing locally via the browser for easier API testing. + builder.AddDotNetConfig(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink")); +#endif + + return builder; + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index d2dc7e35..2ce5db92 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,6 +1,5 @@ using System.Net.Http.Headers; using System.Security.Cryptography; -using Azure.Identity; using Devlooped.Sponsors; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Extensibility; @@ -10,105 +9,97 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.JsonWebTokens; +using Octokit.Webhooks; +using Octokit.Webhooks.AzureFunctions; -class Program -{ - static void Main(string[] args) +var host = new HostBuilder() + .ConfigureAppConfiguration(builder => builder.Configure()) + .ConfigureFunctionsWebApplication(builder => { - var host = new HostBuilder() - .ConfigureAppConfiguration(builder => - { - builder.AddUserSecrets("A85AC898-E41C-4D9D-AD9B-52ED748D9901"); - // Optionally, use key vault for secrets instead of plain-text app service configuration - if (Environment.GetEnvironmentVariable("AZURE_KEYVAULT") is string kv) - builder.AddAzureKeyVault(new Uri($"https://{kv}.vault.azure.net/"), new DefaultAzureCredential()); - + builder.UseFunctionContextAccessor(); + builder.UseErrorLogging(); #if DEBUG - // Allows using SL config for local development. - // In particular, the telemetry module will inject the local id as if had been received from 'x-telemetry-id' header - // when testing locally via the browser for easier API testing. - builder.AddDotNetConfig(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink")); + builder.UseGitHubDeviceFlowAuthentication(); #endif - }) - .ConfigureFunctionsWebApplication(builder => + builder.UseAppServiceAuthentication(); + builder.UseGitHubAuthentication(populateEmails: true, verifiedOnly: true); + builder.UseClaimsPrincipal(); + builder.UseActivityTelemetry(); + }) + .ConfigureServices(services => + { + // Register first so it initializes always before every other initializer. + services.AddSingleton(); + + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + services.AddActivityTelemetry(); + + services.AddMemoryCache(); + services.AddOptions(); + services.AddSingleton, Lazy>(sp => new(() => sp.GetRequiredService())); + + JsonWebTokenHandler.DefaultMapInboundClaims = false; + + services + .AddOptions() + .Configure((options, configuration) => { - builder.UseFunctionContextAccessor(); - builder.UseErrorLogging(); -#if DEBUG - builder.UseGitHubDeviceFlowAuthentication(); -#endif - builder.UseAppServiceAuthentication(); - builder.UseGitHubAuthentication(populateEmails: true, verifiedOnly: true); - builder.UseClaimsPrincipal(); - builder.UseActivityTelemetry(); - }) - .ConfigureServices(services => + configuration.GetSection("SponsorLink").Bind(options); + }); + + services.AddOptions() + .Configure((options, configuration) => { - // Register first so it initializes always before every other initializer. - services.AddSingleton(); - - services.AddApplicationInsightsTelemetryWorkerService(); - services.ConfigureFunctionsApplicationInsights(); - services.AddActivityTelemetry(); - - services.AddMemoryCache(); - services.AddOptions(); - services.AddSingleton, Lazy>(sp => new(() => sp.GetRequiredService())); - - JsonWebTokenHandler.DefaultMapInboundClaims = false; - - services - .AddOptions() - .Configure((options, configuration) => - { - configuration.GetSection("SponsorLink").Bind(options); - }); - - services.AddHttpClient().ConfigureHttpClientDefaults(defaults => defaults.ConfigureHttpClient(http => - { - http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ThisAssembly.Info.Product, ThisAssembly.Info.InformationalVersion)); - })); - - // Add sponsorable client using the GH_TOKEN for GitHub API access - services.AddHttpClient("sponsorable", (sp, http) => - { - var config = sp.GetRequiredService(); - if (config["GitHub:Token"] is not { Length: > 0 } ghtoken) - throw new InvalidOperationException("Missing required configuration 'GitHub:Token'"); - - http.BaseAddress = new Uri("https://api.github.com"); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", ghtoken); - }); - - // Add sponsor client using the current invocation claims for GitHub API access - services.AddScoped(); - services.AddHttpClient("sponsor", http => - { - http.BaseAddress = new Uri("https://api.github.com"); - }).AddHttpMessageHandler(); - - services.AddGraphQueryClient(); - - // RSA key for JWT signing - services.AddSingleton(sp => - { - var options = sp.GetRequiredService>(); - if (string.IsNullOrEmpty(options.Value.PrivateKey)) - throw new InvalidOperationException($"Missing required configuration 'SponsorLink:{nameof(SponsorLinkOptions.PrivateKey)}'"); - - // The key (as well as the yaml manifest) can be generated using sponsors init - // Install with: gh extension install devlooped/gh-sponsors - // See: https://github.com/devlooped/gh-sponsors - var rsa = RSA.Create(); - rsa.ImportRSAPrivateKey(Convert.FromBase64String(options.Value.PrivateKey), out _); - - return rsa; - }); - - services.AddSingleton(); - }) - .Build(); - - host.Run(); - } -} \ No newline at end of file + configuration.GetSection("Pushover").Bind(options); + }); + + services.AddHttpClient().ConfigureHttpClientDefaults(defaults => defaults.ConfigureHttpClient(http => + { + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ThisAssembly.Info.Product, ThisAssembly.Info.InformationalVersion)); + })); + + // Add sponsorable client using the GH_TOKEN for GitHub API access + services.AddHttpClient("sponsorable", (sp, http) => + { + var config = sp.GetRequiredService(); + if (config["GitHub:Token"] is not { Length: > 0 } ghtoken) + throw new InvalidOperationException("Missing required configuration 'GitHub:Token'"); + + http.BaseAddress = new Uri("https://api.github.com"); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", ghtoken); + }); + + // Add sponsor client using the current invocation claims for GitHub API access + services.AddScoped(); + services.AddHttpClient("sponsor", http => + { + http.BaseAddress = new Uri("https://api.github.com"); + }).AddHttpMessageHandler(); + + services.AddGraphQueryClient(); + + // RSA key for JWT signing + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>(); + if (string.IsNullOrEmpty(options.Value.PrivateKey)) + throw new InvalidOperationException($"Missing required configuration 'SponsorLink:{nameof(SponsorLinkOptions.PrivateKey)}'"); + + // The key (as well as the yaml manifest) can be generated using sponsors init + // Install with: gh extension install devlooped/gh-sponsors + // See: https://github.com/devlooped/gh-sponsors + var rsa = RSA.Create(); + rsa.ImportRSAPrivateKey(Convert.FromBase64String(options.Value.PrivateKey), out _); + + return rsa; + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }) + .ConfigureGitHubWebhooks(new ConfigurationBuilder().Configure().Build()["GitHub:Secret"]) + .Build(); + +host.Run(); diff --git a/src/Web/SponsorLink.Legacy.cs b/src/Web/SponsorLink.Legacy.cs index 9f3fd419..40ba1569 100644 --- a/src/Web/SponsorLink.Legacy.cs +++ b/src/Web/SponsorLink.Legacy.cs @@ -10,4 +10,11 @@ partial class SponsorLink [Function("legacy-sync")] public static IActionResult LegacySyncAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sync")] HttpRequest req) => new RedirectResult("me", true, true); + + /// + /// Backwards compatibility for pre-beta endpoint. + /// + [Function("legacy-user")] + public static IActionResult LegacyUserAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "user")] HttpRequest req) + => new RedirectResult("view", true, true); } \ No newline at end of file diff --git a/src/Web/SponsorLink.cs b/src/Web/SponsorLink.cs index d9f7225d..1ee95fea 100644 --- a/src/Web/SponsorLink.cs +++ b/src/Web/SponsorLink.cs @@ -21,69 +21,10 @@ namespace Devlooped.Sponsors; /// partial class SponsorLink(IConfiguration configuration, IHttpClientFactory httpFactory, SponsorsManager sponsors, RSA rsa, IWebHostEnvironment host, ILogger logger) { - static ActivitySource tracer = new("Devlooped.Sponsors"); + static ActivitySource tracer = ActivityTracer.Source; static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - /// - /// Helper to visualize the user's claims and the request/response headers as available to the backend. - /// - [Function("user")] - public async Task UserAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) - { - using var activity = tracer.StartActivity(); - - if (!configuration.TryGetClientId(logger, out var clientId)) - return new StatusCodeResult(500); - - if (ClaimsPrincipal.Current is not { Identity.IsAuthenticated: true } principal) - { - // Implement manual auto-redirect to GitHub, since we cannot turn it on in the portal - // or the token-based principal population won't work. - // Never redirect requests for JWT, as they are likely from a CLI or other non-browser client. - if (!req.Headers.Accept.Contains("application/jwt") && !string.IsNullOrEmpty(clientId)) - { - var redirectHost = host.IsDevelopment() ? - "donkey-emerging-civet.ngrok-free.app" : req.Headers["Host"].ToString(); - - return new RedirectResult($"https://github.com/login/oauth/authorize?client_id={clientId}&scope=read:user%20read:org%20user:email&redirect_uri=https://{redirectHost}/.auth/login/github/callback&state=redir=/me"); - } - - logger.LogError("Ensure GitHub identity provider is configured for the functions app."); - - // Otherwise, just 401 - return new UnauthorizedResult(); - } - - using var http = httpFactory.CreateClient("sponsor"); - var response = await http.GetAsync("https://api.github.com/user"); - - var emails = await http.GetFromJsonAsync("https://api.github.com/user/emails"); - var body = await response.Content.ReadFromJsonAsync(); - body?.Add("emails", emails); - - // Claims can have duplicates, so we group them and turn them into arrays, which is what JWT does too. - var claims = principal.Claims.GroupBy(x => x.Type) - .Select(g => new { g.Key, Value = (object)(g.Count() == 1 ? g.First().Value : g.Select(x => x.Value).ToArray()) }) - .ToDictionary(x => x.Key, x => x.Value); - - // Allows the client to authenticate directly with the OAuth app if needed too. - if (!string.IsNullOrEmpty(clientId)) - claims["client_id"] = clientId; - - return new JsonResult(new - { - body, - claims, - request = host.IsDevelopment() ? req.Headers.ToDictionary(x => x.Key, x => x.Value.ToString().Trim('"')) : null, - response = host.IsDevelopment() ? response.Headers.ToDictionary(x => x.Key, x => string.Join(',', x.Value)) : null - }) - { - StatusCode = (int)response.StatusCode, - SerializerSettings = JsonOptions.Default - }; - } - /// /// Returns the sponsorable manifest from . /// @@ -129,7 +70,7 @@ public async Task GetManifest([HttpTrigger(AuthorizationLevel.Ano /// Returns the sponsorable public key in JWK format. /// [Function("jwk")] - public async Task GetPublicKey([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) + public async Task PublicKey([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) { using var activity = tracer.StartActivity(); var manifest = await sponsors.GetManifestAsync(); @@ -146,7 +87,7 @@ public async Task GetPublicKey([HttpTrigger(AuthorizationLevel.An /// Depending on the Accept header, returns a JWT or JSON manifest of the authenticated user's claims. /// [Function("me")] - public async Task SyncAsync([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "me")] HttpRequest req) + public async Task Sync([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "me")] HttpRequest req) { using var activity = tracer.StartActivity(); @@ -277,4 +218,63 @@ public async Task Health([HttpTrigger(AuthorizationLevel.Anonymou StatusCode = (int)response.StatusCode }; } + + /// + /// Helper to visualize the user's claims and the request/response headers as available to the backend. + /// + [Function("view")] + public async Task View([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req) + { + using var activity = tracer.StartActivity(); + + if (!configuration.TryGetClientId(logger, out var clientId)) + return new StatusCodeResult(500); + + if (ClaimsPrincipal.Current is not { Identity.IsAuthenticated: true } principal) + { + // Implement manual auto-redirect to GitHub, since we cannot turn it on in the portal + // or the token-based principal population won't work. + // Never redirect requests for JWT, as they are likely from a CLI or other non-browser client. + if (!req.Headers.Accept.Contains("application/jwt") && !string.IsNullOrEmpty(clientId)) + { + var redirectHost = host.IsDevelopment() ? + "donkey-emerging-civet.ngrok-free.app" : req.Headers["Host"].ToString(); + + return new RedirectResult($"https://github.com/login/oauth/authorize?client_id={clientId}&scope=read:user%20read:org%20user:email&redirect_uri=https://{redirectHost}/.auth/login/github/callback&state=redir=/me"); + } + + logger.LogError("Ensure GitHub identity provider is configured for the functions app."); + + // Otherwise, just 401 + return new UnauthorizedResult(); + } + + using var http = httpFactory.CreateClient("sponsor"); + var response = await http.GetAsync("https://api.github.com/user"); + + var emails = await http.GetFromJsonAsync("https://api.github.com/user/emails"); + var body = await response.Content.ReadFromJsonAsync(); + body?.Add("emails", emails); + + // Claims can have duplicates, so we group them and turn them into arrays, which is what JWT does too. + var claims = principal.Claims.GroupBy(x => x.Type) + .Select(g => new { g.Key, Value = (object)(g.Count() == 1 ? g.First().Value : g.Select(x => x.Value).ToArray()) }) + .ToDictionary(x => x.Key, x => x.Value); + + // Allows the client to authenticate directly with the OAuth app if needed too. + if (!string.IsNullOrEmpty(clientId)) + claims["client_id"] = clientId; + + return new JsonResult(new + { + body, + claims, + request = host.IsDevelopment() ? req.Headers.ToDictionary(x => x.Key, x => x.Value.ToString().Trim('"')) : null, + response = host.IsDevelopment() ? response.Headers.ToDictionary(x => x.Key, x => string.Join(',', x.Value)) : null + }) + { + StatusCode = (int)response.StatusCode, + SerializerSettings = JsonOptions.Default + }; + } } \ No newline at end of file diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index 59e8de02..703bdb73 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -20,11 +20,13 @@ + + diff --git a/src/Web/Webhook.cs b/src/Web/Webhook.cs new file mode 100644 index 00000000..b3272187 --- /dev/null +++ b/src/Web/Webhook.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Configuration; +using Octokit; +using Octokit.Webhooks; +using Octokit.Webhooks.Events; +using Octokit.Webhooks.Events.IssueComment; +using Octokit.Webhooks.Events.Issues; +using Octokit.Webhooks.Events.Sponsorship; + +namespace Devlooped.Sponsors; + +public class Webhook(SponsorsManager manager, IConfiguration config, IPushover notifier) : WebhookEventProcessor +{ + static ActivitySource tracer = ActivityTracer.Source; + + protected override async Task ProcessSponsorshipWebhookAsync(WebhookHeaders headers, SponsorshipEvent payload, SponsorshipAction action) + { + using var activity = tracer.StartActivity("Sponsorship" + action); + activity?.AddEvent(new ActivityEvent(action)); + manager.RefreshSponsors(); + await base.ProcessSponsorshipWebhookAsync(headers, payload, action); + } + + protected override async Task ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action) + { + using var activity = tracer.StartActivity("IssueComment"); + activity?.AddEvent(new ActivityEvent(action)); + + activity?.SetTag("sender", payload.Sender?.Login); + activity?.SetTag("repo", payload.Repository?.FullName); + activity?.SetTag("issue", payload.Issue.Number.ToString()); + activity?.SetTag("comment", payload.Comment.Id.ToString()); + + if (await manager.FindSponsorAsync(payload.Sender?.Login) is { } sponsor && sponsor.Kind != SponsorTypes.Team) + { + if (sponsor.Tier.Meta.TryGetValue("tier", out var tier)) + activity?.SetTag("sponsor", tier); + else + activity?.SetTag("sponsor", "sponsor"); + + if (action == IssueCommentAction.Created || action == IssueCommentAction.Edited) + { + await notifier.PostAsync(new PushoverMessage + { + Title = $"🗨️ by {payload.Sender?.Login} as {CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tier ?? "")} sponsor", + Message = payload.Comment.Body, + Url = payload.Comment.Url, + UrlTitle = $"View comment on issue #{payload.Issue.Number}", + Priority = PushoverPriority.High + }); + } + } + + await base.ProcessIssueCommentWebhookAsync(headers, payload, action); + } + + protected override async Task ProcessIssuesWebhookAsync(WebhookHeaders headers, IssuesEvent payload, IssuesAction action) + { + await base.ProcessIssuesWebhookAsync(headers, payload, action); + + using var activity = tracer.StartActivity(); + activity?.AddEvent(new ActivityEvent(action)); + + activity?.SetTag("sender", payload.Sender?.Login); + activity?.SetTag("repo", payload.Repository?.FullName); + activity?.SetTag("issue", payload.Issue.Number.ToString()); + + if (await manager.FindSponsorAsync(payload.Sender?.Login) is { } sponsor && sponsor.Kind != SponsorTypes.Team) + { + if (sponsor.Tier.Meta.TryGetValue("tier", out var tier)) + activity?.SetTag("sponsor", tier); + else + activity?.SetTag("sponsor", "sponsor"); + + if (!sponsor.Tier.Meta.TryGetValue("label", out var label)) + label = "sponsor 💜"; + if (!sponsor.Tier.Meta.TryGetValue("color", out var color)) + color = "#D4C5F9"; + + // ensure Issue has the given label applied + if (action == IssuesAction.Opened || + action == IssuesAction.Edited || + action == IssuesAction.Reopened || + action == IssuesAction.Transferred) + { + if (config["GitHub:Token"] is not { } ghtoken || + payload.Issue.Labels.Any(x => x.Name == label) || + payload.Repository is null) + return; + + var client = new GitHubClient(new ProductHeaderValue(ThisAssembly.Info.Product, ThisAssembly.Info.InformationalVersion)) + { + Credentials = new Credentials(config["GitHub:Token"]) + }; + + var definition = await client.Issue.Labels.Get(payload.Repository.Id, label); + if (definition == null) + { + await client.Issue.Labels.Create(payload.Repository.Owner.Login, payload.Repository.Name, new NewLabel(label, color) + { + Description = sponsor.Kind == SponsorTypes.Contributor ? + "Sponsor via contributions" : + tier != null ? + $"{CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tier)} Sponsor" : + "Sponsor" + }); + } + + await client.Issue.Labels.AddToIssue(payload.Repository.Owner.Login, payload.Repository.Name, (int)payload.Issue.Number, [label]); + + await notifier.PostAsync(new PushoverMessage + { + Title = $"🐛 by {payload.Sender?.Login} as {CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tier ?? "")} sponsor", + Message = payload.Issue.Title, + Url = payload.Issue.Url, + UrlTitle = $"View Issue #{payload.Issue.Number}", + Priority = PushoverPriority.High + }); + } + } + } +}