Skip to content

Commit

Permalink
Add GitHub webhook for auto-labeling of sponsor issues
Browse files Browse the repository at this point in the history
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 🤗
<!--
tier: basic
label: sponsor 💜
color: #D4C5F9
-->
```

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.
  • Loading branch information
kzu committed Aug 14, 2024
1 parent 7635caa commit cd81c74
Show file tree
Hide file tree
Showing 17 changed files with 858 additions and 221 deletions.
118 changes: 110 additions & 8 deletions src/Core/GraphQueries.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Scriban;
using System.Security.Principal;
using Scriban;

namespace Devlooped.Sponsors;

Expand All @@ -24,6 +25,21 @@ public static partial class GraphQueries
IsLegacy = true
};

/// <summary>
/// Emails API is not available in GraphQL (yet?). So we must use a legacy query via REST API.
/// </summary>
/// <remarks>
/// See https://github.com/orgs/community/discussions/24389#discussioncomment-3243994
/// </remarks>
public static GraphQuery<string[]> Emails(string user) => new(
$"/users/{user}",
"""
[.email]
""")
{
IsLegacy = true
};

/// <summary>
/// Returns a tuple of (login, type) for the viewer.
/// </summary>
Expand Down Expand Up @@ -418,19 +434,18 @@ ... on User {

/// <summary>
/// Returns the unique repository owners of all repositories the user has contributed
/// commits to.
/// commits to within the last year.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static GraphQuery<string[]> UserContributions(string user, int pageSize = 100) => new(
"""
query($login: String!, $endCursor: String, $count: Int!) {
user(login: $login) {
repositoriesContributedTo(first: $count, includeUserRepositories: true, contributionTypes: [COMMIT], after: $endCursor) {
nodes {
nameWithOwner,
owner {
login
}
Expand Down Expand Up @@ -509,6 +524,29 @@ ... on User {
}
};

public static GraphQuery<Account> 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 }
}
};

/// <summary>
/// Tries to get a user account.
/// </summary>
Expand Down Expand Up @@ -639,12 +677,13 @@ public static GraphQuery<string[]> IsSponsoredBy(string sponsorable, params stri
sponsorsListing {
tiers(first: 100){
nodes {
id,
name,
description,
monthlyPriceInDollars,
isOneTime,
closestLesserValueTier {
name
id
},
}
}
Expand All @@ -654,12 +693,13 @@ public static GraphQuery<string[]> IsSponsoredBy(string sponsorable, params stri
sponsorsListing {
tiers(first: 100){
nodes {
id,
name,
description,
monthlyPriceInDollars,
isOneTime,
closestLesserValueTier {
name
id
},
}
}
Expand All @@ -668,7 +708,7 @@ public static GraphQuery<string[]> 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 =
Expand All @@ -677,6 +717,68 @@ public static GraphQuery<string[]> IsSponsoredBy(string sponsorable, params stri
}
};

/// <summary>
/// If the sponsorable is a user, we don't support pagination for now and it will
/// return the maximum limit of 100 entities.
/// </summary>
public static GraphQuery<Sponsor[]> 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 }
}
};

/// <summary>
/// Gets the verified sponsoring organizations for a given sponsorable organization.
/// </summary>
Expand Down
6 changes: 5 additions & 1 deletion src/Core/HttpGraphQueryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ await JQ.ExecuteAsync(raw, query.JQ) :
if (typed is IEnumerable<object> array)
items.AddRange(array);

info = JsonSerializer.Deserialize<PageInfo>(await JQ.ExecuteAsync(raw, ".. | .pageInfo? | values"), JsonOptions.Default);
var pageInfoRaw = await JQ.ExecuteAsync(raw, ".. | .pageInfo? | values");
if (string.IsNullOrEmpty(pageInfoRaw))
break;

info = JsonSerializer.Deserialize<PageInfo>(pageInfoRaw, JsonOptions.Default);
if (info is null || !info.HasNextPage)
break;
}
Expand Down
2 changes: 2 additions & 0 deletions src/Core/JsonOptions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,6 +16,7 @@ static partial class JsonOptions
new()
#endif
{
//Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
Expand Down
159 changes: 159 additions & 0 deletions src/Core/Pushover.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// The message's title, otherwise the app's name is used
/// </summary>
public string? Title { get; set; }

/// <summary>
/// The message to send.
/// </summary>
public string? Message { get; set; }

/// <summary>
/// An image attachment to send with the message.
/// </summary>
public string? Attachment { get; set; }

/// <summary>
/// A supplementary URL to show with the message
/// </summary>
public string? Url { get; set; }

/// <summary>
/// A title for the supplementary URL, otherwise just the URL is shown
/// </summary>
[JsonPropertyName("url_title")]
public string? UrlTitle { get; set; }

/// <summary>
/// The priority of the message
/// </summary>
public PushoverPriority Priority { get; set; } = PushoverPriority.Normal;

/// <summary>
/// The name of the sound to use with
/// </summary>
public string Sound { get; set; } = "pushover";

public PushoverMessage() { }

public PushoverMessage(string message) => Message = message;
}

public static class PushoverSounds
{
/// <summary>Pushover (default)</summary>
public const string Pushover = "pushover";
/// <summary>Bike</summary>
public const string Bike = "bike";
/// <summary>Bugle</summary>
public const string Bugle = "bugle";
/// <summary>Cash Register</summary>
public const string Cashregister = "cashregister";
/// <summary>Classical</summary>
public const string Classical = "classical";
/// <summary>Cosmic</summary>
public const string Cosmic = "cosmic";
/// <summary>Falling</summary>
public const string Falling = "falling";
/// <summary>Gamelan</summary>
public const string Gamelan = "gamelan";
/// <summary>Incoming</summary>
public const string Incoming = "incoming";
/// <summary>Intermission</summary>
public const string Intermission = "intermission";
/// <summary>Magic</summary>
public const string Magic = "magic";
/// <summary>Mechanical</summary>
public const string Mechanical = "mechanical";
/// <summary>Piano Bar</summary>
public const string Pianobar = "pianobar";
/// <summary>Siren</summary>
public const string Siren = "siren";
/// <summary>Space Alarm</summary>
public const string Spacealarm = "spacealarm";
/// <summary>Tug Boat</summary>
public const string Tugboat = "tugboat";
/// <summary>Alien Alarm (long)</summary>
public const string Alien = "alien";
/// <summary>Climb (long)</summary>
public const string Climb = "climb";
/// <summary>Persistent (long)</summary>
public const string Persistent = "persistent";
/// <summary>Pushover Echo (long)</summary>
public const string Echo = "echo";
/// <summary>Up Down (long)</summary>
public const string Updown = "updown";
/// <summary>Vibrate Only</summary>
public const string Vibrate = "vibrate";
/// <summary>None (silent)</summary>
public const string None = "none";
}

public class Pushover(IHttpClientFactory factory, IOptions<PushoverOptions> 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();
}
}
7 changes: 6 additions & 1 deletion src/Core/Records.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> Meta { get; init; } = [];
}
Expand Down
Loading

0 comments on commit cd81c74

Please sign in to comment.