public async Task <IChangeSummary> AddOrUpdateOrganizationWebhooks(ShipHubContext context, IGitHubOrganizationAdmin admin) { var changes = ChangeSummary.Empty; var hook = await context.Hooks.AsNoTracking().SingleOrDefaultAsync(x => x.OrganizationId == _orgId); context.Database.Connection.Close(); // If our last operation on this repo hook resulted in error, delay. if (hook?.LastError != null && hook?.LastError.Value > DateTimeOffset.UtcNow.Subtract(HookErrorDelay)) { return(changes); // Wait to try later. } // There are now a few cases to handle // If there is no record of a hook, try to make one. // If there is an incomplete record, try to make it. // If there is an errored record, sleep or retry if (hook?.GitHubId == null) { // GitHub will immediately send a ping when the webhook is created. // To avoid any chance for a race, add the Hook to the DB first, then // create on GitHub. HookTableType newHook = null; if (hook == null) { newHook = await context.CreateHook(Guid.NewGuid(), string.Join(",", RequiredEvents), organizationId : _orgId); } else { newHook = new HookTableType() { Id = hook.Id, Secret = hook.Secret, Events = string.Join(",", RequiredEvents), }; } // Assume failure until we succeed newHook.LastError = DateTimeOffset.UtcNow; try { var hookList = await admin.OrganizationWebhooks(_login); if (!hookList.IsOk) { this.Info($"Unable to list hooks for {_login}. {hookList.Status} {hookList.Error}"); return(changes); } var existingHooks = hookList.Result .Where(x => x.Name.Equals("web")) .Where(x => x.Config.Url.StartsWith($"https://{_apiHostName}/", StringComparison.OrdinalIgnoreCase)); // Delete any existing hooks that already point back to us - don't // want to risk adding multiple Ship hooks. foreach (var existingHook in existingHooks) { var deleteResponse = await admin.DeleteOrganizationWebhook(_login, existingHook.Id); if (!deleteResponse.Succeeded) { this.Info($"Failed to delete existing hook ({existingHook.Id}) for org '{_login}' {deleteResponse.Status} {deleteResponse.Error}"); } } var addHookResponse = await admin.AddOrganizationWebhook( _login, new gh.Webhook() { Name = "web", Active = true, Events = RequiredEvents, Config = new gh.WebhookConfiguration() { Url = $"https://{_apiHostName}/webhook/org/{_orgId}", ContentType = "json", Secret = newHook.Secret.ToString(), }, }); if (addHookResponse.Succeeded) { newHook.GitHubId = addHookResponse.Result.Id; newHook.LastError = null; changes = await context.BulkUpdateHooks(hooks : new[] { newHook }); } else { this.Error($"Failed to add hook for org '{_login}' ({_orgId}): {addHookResponse.Status} {addHookResponse.Error}"); } } catch (Exception e) { e.Report($"Failed to add hook for org '{_login}' ({_orgId})"); // Save LastError await context.BulkUpdateHooks(hooks : new[] { newHook }); } } else if (!RequiredEvents.SetEquals(hook.Events.Split(','))) { var editHook = new HookTableType() { Id = hook.Id, GitHubId = hook.GitHubId, Secret = hook.Secret, Events = hook.Events, LastError = DateTimeOffset.UtcNow, // Default to faulted. }; try { this.Info($"Updating webhook {_login}/{hook.GitHubId} from [{hook.Events}] to [{string.Join(",", RequiredEvents)}]"); var editResponse = await admin.EditOrganizationWebhookEvents(_login, (long)hook.GitHubId, RequiredEvents); if (editResponse.Succeeded) { editHook.LastError = null; editHook.GitHubId = editResponse.Result.Id; editHook.Events = string.Join(",", editResponse.Result.Events); await context.BulkUpdateHooks(hooks : new[] { editHook }); } else if (editResponse.Status == HttpStatusCode.NotFound) { // Our record is out of date. this.Info($"Failed to edit hook for org '{_login}'. Deleting our hook record. {editResponse.Status} {editResponse.Error}"); changes = await context.BulkUpdateHooks(deleted : new[] { editHook.Id }); } else { throw new Exception($"Failed to edit hook for org '{_login}': {editResponse.Status} {editResponse.Error}"); } } catch (Exception e) { e.Report(); // Save LastError await context.BulkUpdateHooks(hooks : new[] { editHook }); } } return(changes); }
public async Task <IHttpActionResult> ReceiveHook(string type, long id) { if (!IsRequestFromGitHub()) { Log.Info($"Rejecting webhook request from impersonator: {GetIPAddress()} {Request.RequestUri}"); return(BadRequest("Not you.")); } // Invalid inputs can make these fail. That's ok. var eventName = Request.ParseHeader(EventHeaderName, x => x); var deliveryId = Request.ParseHeader(DeliveryIdHeaderName, x => Guid.Parse(x)); // signature of the form "sha1=..." var signature = Request.ParseHeader(SignatureHeaderName, x => SoapHexBinary.Parse(x.Substring(5)).Value); using (var context = new ShipHubContext()) { Hook hook; if (type == "org") { hook = await context.Hooks.AsNoTracking().SingleOrDefaultAsync(x => x.OrganizationId == id); } else { hook = await context.Hooks.AsNoTracking().SingleOrDefaultAsync(x => x.RepositoryId == id); } if (hook == null) { // I don't care anymore. This is GitHub's problem. // They should support unsubscribing from a hook with a special response code or body. // We may not even have credentials to remove the hook anymore. return(NotFound()); } var secret = Encoding.UTF8.GetBytes(hook.Secret.ToString()); var webhookEventActor = await _grainFactory.GetGrain <IWebhookEventActor>(0); // Stateless worker grain with single pool (0) var debugInfo = $"[{type}:{id}#{eventName}/{deliveryId}]"; Task hookTask = null; switch (eventName) { case "commit_comment": { var payload = await ReadPayloadAsync <CommitCommentPayload>(signature, secret); hookTask = webhookEventActor.CommitComment(DateTimeOffset.UtcNow, payload); } break; case "issue_comment": { var payload = await ReadPayloadAsync <IssueCommentPayload>(signature, secret); hookTask = webhookEventActor.IssueComment(DateTimeOffset.UtcNow, payload); } break; case "issues": { var payload = await ReadPayloadAsync <IssuesPayload>(signature, secret); hookTask = webhookEventActor.Issues(DateTimeOffset.UtcNow, payload); } break; case "label": { var payload = await ReadPayloadAsync <LabelPayload>(signature, secret); hookTask = webhookEventActor.Label(DateTimeOffset.UtcNow, payload); } break; case "milestone": { var payload = await ReadPayloadAsync <MilestonePayload>(signature, secret); hookTask = webhookEventActor.Milestone(DateTimeOffset.UtcNow, payload); } break; case "ping": await ReadPayloadAsync <object>(signature, secret); // read payload to validate signature break; case "pull_request_review_comment": { var payload = await ReadPayloadAsync <PullRequestReviewCommentPayload>(signature, secret); hookTask = webhookEventActor.PullRequestReviewComment(DateTimeOffset.UtcNow, payload); } break; case "pull_request_review": { var payload = await ReadPayloadAsync <PullRequestReviewPayload>(signature, secret); hookTask = webhookEventActor.PullRequestReview(DateTimeOffset.UtcNow, payload); } break; case "pull_request": { var payload = await ReadPayloadAsync <PullRequestPayload>(signature, secret); hookTask = webhookEventActor.PullRequest(DateTimeOffset.UtcNow, payload); } break; case "push": { var payload = await ReadPayloadAsync <PushPayload>(signature, secret); hookTask = webhookEventActor.Push(DateTimeOffset.UtcNow, payload); break; } case "repository": { var payload = await ReadPayloadAsync <RepositoryPayload>(signature, secret); hookTask = webhookEventActor.Repository(DateTimeOffset.UtcNow, payload); break; } case "status": { var payload = await ReadPayloadAsync <StatusPayload>(signature, secret); hookTask = webhookEventActor.Status(DateTimeOffset.UtcNow, payload); break; } default: Log.Error($"Webhook event '{eventName}' is not handled. Either support it or don't subscribe to it."); break; } // Just in case if (hookTask == null && eventName != "ping") { Log.Error($"Webhook event '{eventName}' does net set the {nameof(hookTask)}. Failures will be silent."); } hookTask?.LogFailure(debugInfo); // Reset the ping count so this webhook won't get reaped. await context.BulkUpdateHooks(seen : new[] { hook.Id }); } return(StatusCode(HttpStatusCode.Accepted)); }
public async Task Run(IAsyncCollector <ChangeMessage> notifyChanges) { var maxPingCount = 3; var batchSize = 100; var numStaleHooks = 0; do { var now = UtcNow; var staleDateTimeOffset = now.AddDays(-1); var pingTime = now.AddMinutes(-30); var pingTasks = new List <Task <GitHubResponse <bool> > >(); ChangeSummary changes; using (var context = new ShipHubContext()) { var staleHooks = context.Hooks .Where(x => (x.LastSeen == null || x.LastSeen <= staleDateTimeOffset) && (x.LastPing == null || x.LastPing <= pingTime) && (x.GitHubId != null)) .Take(batchSize) .ToList(); numStaleHooks = staleHooks.Count; var deleted = new HashSet <long>(); var pinged = new HashSet <long>(); foreach (var hook in staleHooks) { if (hook.PingCount >= maxPingCount) { // We've pinged this hook several times now and never heard back. // We'll remove it. The hook will get re-added later when a user // with admin privileges for that app uses Ship again and triggers // another sync. deleted.Add(hook.Id); } else if (hook.RepositoryId != null) { // Find some account with admin privileges for this repo that we can // use to ping. var createHookInfo = await context.AccountRepositories .AsNoTracking() .Where(x => x.Admin) .Where(x => x.RepositoryId == hook.RepositoryId) .Where(x => x.Account.Tokens.Any()) .Where(x => x.Account.RateLimit > GitHubRateLimit.RateLimitFloor || x.Account.RateLimitReset < DateTime.UtcNow) .Select(x => new { UserId = x.AccountId, RepoFullName = x.Repository.FullName }) .FirstOrDefaultAsync(); if (createHookInfo != null) { var client = await _grainFactory.GetGrain <IGitHubActor>(createHookInfo.UserId); pingTasks.Add(client.PingRepositoryWebhook(createHookInfo.RepoFullName, (long)hook.GitHubId)); pinged.Add(hook.Id); } else { // We no longer have any credentials to manage the hook. // Mark it pinged, so that it will eventually be collected as inactive // Don't delete immediately in case it's a temporary lapse. pinged.Add(hook.Id); } } else if (hook.OrganizationId != null) { var createHookInfo = await context.OrganizationAccounts .AsNoTracking() .Where(x => x.Admin) .Where(x => x.OrganizationId == hook.OrganizationId) .Where(x => x.User.Tokens.Any()) .Where(x => x.User.RateLimit > GitHubRateLimit.RateLimitFloor || x.User.RateLimitReset < DateTime.UtcNow) .Select(x => new { UserId = x.UserId, OrgLogin = x.Organization.Login }) .FirstOrDefaultAsync(); if (createHookInfo != null) { var client = await _grainFactory.GetGrain <IGitHubActor>(createHookInfo.UserId); pingTasks.Add(client.PingOrganizationWebhook(createHookInfo.OrgLogin, (long)hook.GitHubId)); pinged.Add(hook.Id); } else { // We no longer have any credentials to manage the hook. // Mark it pinged, so that it will eventually be collected as inactive. // Don't delete immediately in case it's a temporary lapse. pinged.Add(hook.Id); } } else { throw new InvalidOperationException("RepositoryId or OrganizationId should be non null"); } } changes = await context.BulkUpdateHooks(deleted : deleted, pinged : pinged); } if (!changes.IsEmpty) { await notifyChanges.AddAsync(new ChangeMessage(changes)); } await Task.WhenAll(pingTasks); } while (numStaleHooks == batchSize); }