Ejemplo n.º 1
0
        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);
        }
Ejemplo n.º 2
0
        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));
        }
Ejemplo n.º 3
0
        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);
        }