From 6ced03002fa17d9e689b94c01837eb64c3e73a8f Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Tue, 27 Oct 2026 14:56:02 -0700 Subject: [PATCH] Receive GitHub App webhooks for installation changes and revoked authorizations POST /api/github_app/webhooks verifies X-Hub-Signature-256 under github_app_webhook_secret (falling back to GITHUB_APP_WEBHOOK_SECRET; 404 when neither is set), records the X-GitHub-Delivery id so a redelivery is a no-op, answers 202 and applies the delivery in GithubWebhookJob: - installation deleted / suspend / unsuspend / new_permissions_accepted update the linked installation's removed_at, suspended_at and permissions - installation_repositories removed drops repositories from the owner's selection; added needs nothing stored (the picker lists live) - github_app_authorization revoked deletes the user's stored connection when connections authorize through the App's client id Deliveries for installations no owner linked, and other events, are accepted and ignored. Ships migration 029 for the delivery table. Closes #641 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCEJHsJpjXpDa9Kuq9i4Yg --- CHANGELOG.md | 16 + .../api/github_app_webhooks_controller.rb | 98 ++++++ .../jobs/action_agent/github_webhook_job.rb | 102 ++++++ .../action_agent/github_installation.rb | 3 +- .../action_agent/github_webhook_delivery.rb | 45 +++ actionagent/config/routes.rb | 5 + actionagent/lib/action_agent.rb | 15 + .../templates/action_agent.rb.erb | 6 + ...ive_agent_github_webhook_deliveries.rb.erb | 23 ++ .../github_app_authorization_revoked.json | 10 + .../github_webhooks/installation_created.json | 53 +++ .../github_webhooks/installation_deleted.json | 59 ++++ ...installation_new_permissions_accepted.json | 53 +++ .../installation_repositories_added.json | 55 +++ .../installation_repositories_removed.json | 55 +++ .../github_webhooks/installation_suspend.json | 58 ++++ .../installation_unsuspend.json | 52 +++ .../fixtures/files/github_webhooks/ping.json | 20 ++ actionagent/test/github_app_webhooks_test.rb | 318 ++++++++++++++++++ actionagent/test/install_generator_test.rb | 23 ++ docs/framework/dashboard.md | 41 ++- ..._active_agent_github_webhook_deliveries.rb | 23 ++ test/dummy/db/schema.rb | 12 +- 23 files changed, 1142 insertions(+), 3 deletions(-) create mode 100644 actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb create mode 100644 actionagent/app/jobs/action_agent/github_webhook_job.rb create mode 100644 actionagent/app/models/action_agent/github_webhook_delivery.rb create mode 100644 actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb create mode 100644 actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_created.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_deleted.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_suspend.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json create mode 100644 actionagent/test/fixtures/files/github_webhooks/ping.json create mode 100644 actionagent/test/github_app_webhooks_test.rb create mode 100644 test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a750b32a4..1797b8a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **GitHub App webhooks** (`actionagent`). `POST /api/github_app/webhooks` + receives the App's deliveries, verified with `X-Hub-Signature-256` under + `ActionAgent.github_app_webhook_secret` (falling back to + `GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies + them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked + installation removed, suspend and unsuspend set and clear `suspended_at`, + accepted permissions are recorded, a repository removed from the + installation leaves the owner's selection, and a revoked App authorization + deletes the user's stored connection when connections authorize through the + App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery + is a no-op. Ships a new migration, + `create_active_agent_github_webhook_deliveries`; run + `rails generate action_agent:install` and `rails db:migrate` to add it. + ### Fixed - **Boot on Ruby 3.4 and 4.0 with bootsnap 1.24.6 or newer** (`actionagent`). diff --git a/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb b/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb new file mode 100644 index 000000000..fe1405632 --- /dev/null +++ b/actionagent/app/controllers/action_agent/api/github_app_webhooks_controller.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "openssl" + +module ActionAgent + module Api + # POST /api/github_app/webhooks: the GitHub App's webhook + # deliveries (installation, installation_repositories, + # github_app_authorization), applied by GithubWebhookJob. + # + # GitHub's signature authenticates a delivery on its own, so this is not a + # dashboard controller and sees no session, no forgery protection and none + # of the host's controller concerns. A delivery is accepted only when its + # X-Hub-Signature-256 is the HMAC-SHA256 of the raw body under + # ActionAgent.github_app_webhook_secret; with no secret configured there + # is no endpoint (404). + # + # The answer is 202 as soon as a delivery is recorded and enqueued, or + # recognized as one already recorded (GithubWebhookDelivery), or found to + # be an event the dashboard does not handle. Only a failure to enqueue + # answers otherwise, and leaves the delivery unrecorded so a redelivery + # of it is processed. + # + # The body may be JSON, or a form whose +payload+ field holds the JSON, + # as GitHub sends it when the App's content type is + # application/x-www-form-urlencoded. The signature covers the raw body + # either way. + class GithubAppWebhooksController < ActionController::API + wrap_parameters false + + SIGNATURE_HEADER = "X-Hub-Signature-256" + EVENT_HEADER = "X-GitHub-Event" + DELIVERY_HEADER = "X-GitHub-Delivery" + + def create + secret = ActionAgent.github_app_webhook_secret + return head(:not_found) if secret.blank? + + body = request.raw_post.to_s + unless signed?(body, secret) + return render(json: { error: "Invalid or missing #{SIGNATURE_HEADER}" }, status: :unauthorized) + end + + event = request.headers[EVENT_HEADER].to_s + return accepted("ignored") unless event.in?(GithubWebhookJob::EVENTS) + + delivery_id = request.headers[DELIVERY_HEADER].to_s + unless delivery_id.match?(/\A[A-Za-z0-9-]{1,64}\z/) + return render(json: { error: "#{DELIVERY_HEADER} is required" }, status: :bad_request) + end + + payload = parse(body) + return render(json: { error: "The delivery's body is not a JSON object" }, status: :bad_request) unless payload + + job_payload = GithubWebhookJob.payload_for(event, payload) + recorded = GithubWebhookDelivery.record!( + delivery_id: delivery_id, event: event, + event_action: job_payload["action"].presence&.first(64), + installation_id: job_payload.dig("installation", "id").presence&.to_i + ) + return accepted("duplicate") unless recorded + + begin + GithubWebhookJob.perform_later(event, job_payload) + rescue StandardError + # Not left recorded, so a redelivery of it is processed. + GithubWebhookDelivery.forget!(delivery_id) + raise + end + + accepted("queued") + end + + private + + # Constant time, whatever the header holds. + def signed?(body, secret) + given = request.headers[SIGNATURE_HEADER] + return false unless given.is_a?(String) && given.start_with?("sha256=") + + expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + ActiveSupport::SecurityUtils.secure_compare(expected, given) + end + + def parse(body) + body = Rack::Utils.parse_query(body)["payload"].to_s if request.media_type == "application/x-www-form-urlencoded" + data = JSON.parse(body) + data if data.is_a?(Hash) + rescue JSON::ParserError + nil + end + + def accepted(status) + render json: { status: status }, status: :accepted + end + end + end +end diff --git a/actionagent/app/jobs/action_agent/github_webhook_job.rb b/actionagent/app/jobs/action_agent/github_webhook_job.rb new file mode 100644 index 000000000..87accb23d --- /dev/null +++ b/actionagent/app/jobs/action_agent/github_webhook_job.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module ActionAgent + # Applies a GitHub App webhook delivery that Api::GithubAppWebhooksController + # verified and recorded, so Settings -> Integrations learns of a change on + # GitHub when it happens rather than when a token mint is next refused. + # + # +payload+ is the slice of the delivery GithubWebhookJob.payload_for keeps. + # A delivery about an installation no owner linked changes nothing, and so + # does an event or action not handled here. + # + # Each handler sets state from the payload rather than toggling it, so + # running one twice leaves the same result; the delivery record is what + # keeps an old delivery from being replayed over a newer one. + class GithubWebhookJob < ApplicationJob + queue_as :default + + EVENTS = %w[installation installation_repositories github_app_authorization].freeze + + # What the job needs from a delivery's +payload+, so the job's arguments + # carry neither the sender's profile nor every repository's details. + def self.payload_for(event, payload) + installation = payload["installation"].is_a?(Hash) ? payload["installation"] : {} + repositories = ->(key) { Array(payload[key]).filter_map { |repo| repo.slice("id", "full_name") if repo.is_a?(Hash) } } + sender = payload["sender"].is_a?(Hash) ? payload["sender"] : {} + + { + "action" => payload["action"].to_s, + "installation" => installation.slice("id", "permissions", "repository_selection", "suspended_at"), + "repositories_added" => event == "installation_repositories" ? repositories.call("repositories_added") : [], + "repositories_removed" => event == "installation_repositories" ? repositories.call("repositories_removed") : [], + "sender_id" => sender["id"] + } + end + + def perform(event, payload) + case event + when "installation" then installation(payload) + when "installation_repositories" then installation_repositories(payload) + when "github_app_authorization" then authorization(payload) + end + end + + private + + def installation(payload) + record = linked_installation(payload) or return + reported = payload["installation"] + + case payload["action"] + when "deleted" + record.update!(removed_at: Time.current) if record.removed_at.nil? + when "suspend" + record.update!(suspended_at: parse_time(reported["suspended_at"]) || Time.current) + when "unsuspend" + record.update!(suspended_at: nil) + when "new_permissions_accepted" + record.update!(permissions: reported["permissions"]) if reported["permissions"].is_a?(Hash) + end + end + + # The repository picker lists what the installation reaches from GitHub + # each time it opens, so an added repository needs nothing stored; a + # removed one leaves the owner's selection, which is what a checkout + # trusts. + def installation_repositories(payload) + record = linked_installation(payload) or return + return unless payload["action"].in?(%w[added removed]) + + removed = payload["repositories_removed"].map { |repo| repo["id"].to_i }.to_set + attributes = { repositories: record.repositories.reject { |repo| removed.include?(repo["id"].to_i) } } + selection = payload.dig("installation", "repository_selection") + attributes[:repository_selection] = selection if selection.present? + record.update!(attributes) + end + + # GitHub revoked the user's authorization of the App. The App's own user + # tokens are never stored (see Api::GithubInstallationsController), so + # this reaches a stored GithubConnection only when the OAuth connection + # flow authorizes through this same App (github_client_id is the App's + # client id). A connection made through a separate OAuth App is a grant + # this revocation does not touch. + def authorization(payload) + return unless payload["action"] == "revoked" + return if payload["sender_id"].blank? + return unless ActionAgent.github_client_id.present? && ActionAgent.github_client_id == ActionAgent.github_app_client_id + + GithubConnection.where(github_user_id: payload["sender_id"].to_i).destroy_all + end + + def linked_installation(payload) + id = payload.dig("installation", "id") + GithubInstallation.find_by(installation_id: id.to_i) if id.present? + end + + def parse_time(value) + Time.iso8601(value) if value.is_a?(String) + rescue ArgumentError + nil + end + end +end diff --git a/actionagent/app/models/action_agent/github_installation.rb b/actionagent/app/models/action_agent/github_installation.rb index 1af88d1d7..2d681eb58 100644 --- a/actionagent/app/models/action_agent/github_installation.rb +++ b/actionagent/app/models/action_agent/github_installation.rb @@ -49,7 +49,8 @@ def repository(full_name) # Whether GitHub will still mint tokens for this installation, as far as # the dashboard last heard: false once a mint found it removed or - # suspended, until a later mint succeeds or it is linked again. + # suspended, or a webhook delivery reported it (GithubWebhookJob), until + # a later mint succeeds, an unsuspend is delivered, or it is linked again. def usable? removed_at.nil? && suspended_at.nil? end diff --git a/actionagent/app/models/action_agent/github_webhook_delivery.rb b/actionagent/app/models/action_agent/github_webhook_delivery.rb new file mode 100644 index 000000000..da1b410aa --- /dev/null +++ b/actionagent/app/models/action_agent/github_webhook_delivery.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ActionAgent + # A GitHub App webhook delivery the dashboard accepted, by its + # X-GitHub-Delivery id. Api::GithubAppWebhooksController records one before + # it enqueues the delivery's GithubWebhookJob, so a redelivery of the same + # id (from GitHub's retry or its Redeliver button) finds it and is a no-op: + # a replayed +suspend+ cannot undo a later +unsuspend+. + # + # A row instead of Rails.cache: the unique index makes the check and the + # record one atomic insert across every web process, whatever cache store + # the host runs. Rows hold no payload and are pruned after RETENTION, a + # little longer than GitHub keeps a delivery redeliverable. + # + # Not owned: a delivery is about an installation, whichever owner (if any) + # linked it. + class GithubWebhookDelivery < ApplicationRecord + RETENTION = 7.days + + validates :delivery_id, :event, presence: true + + # Records the delivery, and answers whether it is the first time this id + # was seen. Rows older than RETENTION are pruned on the way. + # + # @return [Boolean] false for a redelivery + def self.record!(delivery_id:, event:, event_action: nil, installation_id: nil) + where(created_at: ...RETENTION.ago).delete_all + + # A savepoint, so a duplicate inside a caller's transaction does not + # abort it on PostgreSQL. + transaction(requires_new: true) do + create!(delivery_id: delivery_id, event: event, event_action: event_action, installation_id: installation_id) + end + true + rescue ActiveRecord::RecordNotUnique + false + end + + # Forgets the delivery, so GitHub's redelivery of it is processed. For a + # delivery that was recorded but could not be enqueued. + def self.forget!(delivery_id) + where(delivery_id: delivery_id).delete_all + end + end +end diff --git a/actionagent/config/routes.rb b/actionagent/config/routes.rb index fd4d4c73a..68fc04ee5 100644 --- a/actionagent/config/routes.rb +++ b/actionagent/config/routes.rb @@ -318,6 +318,11 @@ end end + # The GitHub App's webhook deliveries. Authenticated by GitHub's + # signature (github_app_webhook_secret), not a session; the App's + # Webhook URL is /api/github_app/webhooks. + post "github_app/webhooks", to: "github_app_webhooks#create", as: :github_app_webhooks + # Creating the GitHub App from a manifest on a self-hosted dashboard. resource :github_app_manifest, only: [ :create ] do get :callback diff --git a/actionagent/lib/action_agent.rb b/actionagent/lib/action_agent.rb index 565fe4b96..504f5eb6a 100644 --- a/actionagent/lib/action_agent.rb +++ b/actionagent/lib/action_agent.rb @@ -867,6 +867,16 @@ def claude_code_auth=(value) attr_writer :github_app_id, :github_app_private_key, :github_app_slug, :github_app_client_id, :github_app_client_secret + # The secret GitHub signs the App's webhook deliveries with, which + # POST /api/github_app/webhooks verifies. Falls back to + # GITHUB_APP_WEBHOOK_SECRET. Not one of the settings + # github_app_configured? needs: without it the endpoint answers 404 and + # the dashboard learns of installation changes only when a token mint is + # refused. Set the App's Webhook URL to /api/github_app/webhooks + # and subscribe it to Installation and Installation repositories. + # @return [String, nil] + attr_writer :github_app_webhook_secret + # MCP servers the host app itself serves or connects, appended to the # built-in catalog (MCPCatalog) so the MCP Services view lists them and # telemetry traffic attributes to them. Each entry is a hash shaped like @@ -971,6 +981,10 @@ def github_app_client_secret @github_app_client_secret.presence || ENV["GITHUB_APP_CLIENT_SECRET"].presence end + def github_app_webhook_secret + @github_app_webhook_secret.presence || ENV["GITHUB_APP_WEBHOOK_SECRET"].presence + end + # Whether the GitHub App installation flow can run on this install: every # github_app_* setting is present. # @return [Boolean] @@ -1334,6 +1348,7 @@ def reset! @github_app_slug = nil @github_app_client_id = nil @github_app_client_secret = nil + @github_app_webhook_secret = nil @trace_retention = nil @trace_owner_resolver = nil @usage_recorder = nil diff --git a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb index f99d01ee7..f602e4718 100644 --- a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb +++ b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb @@ -215,6 +215,12 @@ ActionAgent.configure do |config| # config.github_app_client_secret = Rails.application.credentials.dig(:github_app, :client_secret) # config.github_app_private_key = Rails.application.credentials.dig(:github_app, :private_key) # + # With the App's webhook pointed at /api/github_app/webhooks, the + # secret its deliveries are signed with (GITHUB_APP_WEBHOOK_SECRET when + # unset). Settings then learns of an uninstall or suspension when it happens. + # + # config.github_app_webhook_secret = Rails.application.credentials.dig(:github_app, :webhook_secret) + # # Or an OAuth App, connected per owner with the callback URL # /api/github_connection/callback (GITHUB_CLIENT_ID and # GITHUB_CLIENT_SECRET when unset). diff --git a/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb b/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb new file mode 100644 index 000000000..7e442d933 --- /dev/null +++ b/actionagent/lib/generators/action_agent/templates/migrations/029_create_active_agent_github_webhook_deliveries.rb.erb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# The GitHub App webhook deliveries the dashboard accepted, kept briefly so a +# redelivery of the same X-GitHub-Delivery is a no-op (see +# ActionAgent::GithubWebhookDelivery). Nothing here is a credential or a +# payload. +class CreateActiveAgentGithubWebhookDeliveries < ActiveRecord::Migration<%= migration_version %> + def change + prefix = ActionAgent.table_name_prefix + + create_table "#{prefix}github_webhook_deliveries" do |t| + # GitHub's X-GitHub-Delivery, a GUID. Unique: a second insert of the + # same delivery is how a redelivery is recognized. + t.string :delivery_id, null: false, limit: 64 + t.string :event, null: false, limit: 64 + t.string :event_action, limit: 64 + t.bigint :installation_id + t.datetime :created_at, null: false + t.index :delivery_id, unique: true, name: "index_#{prefix}webhook_deliveries_on_delivery" + t.index :created_at, name: "index_#{prefix}webhook_deliveries_on_created_at" + end + end +end diff --git a/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json b/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json new file mode 100644 index 000000000..38aa20b70 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/github_app_authorization_revoked.json @@ -0,0 +1,10 @@ +{ + "action": "revoked", + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_created.json b/actionagent/test/fixtures/files/github_webhooks/installation_created.json new file mode 100644 index 000000000..741a5ddfc --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_created.json @@ -0,0 +1,53 @@ +{ + "action": "created", + "installation": { + "id": 78, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json b/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json new file mode 100644 index 000000000..a81419767 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_deleted.json @@ -0,0 +1,59 @@ +{ + "action": "deleted", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + }, + { + "id": 102, + "node_id": "R_kgDO102", + "name": "web", + "full_name": "acme/web", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json b/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json new file mode 100644 index 000000000..ff832202c --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_new_permissions_accepted.json @@ -0,0 +1,53 @@ +{ + "action": "new_permissions_accepted", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read", + "checks": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json new file mode 100644 index 000000000..241dd9de3 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_added.json @@ -0,0 +1,55 @@ +{ + "action": "added", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repository_selection": "selected", + "repositories_added": [ + { + "id": 103, + "node_id": "R_kgDO103", + "name": "docs", + "full_name": "acme/docs", + "private": true + } + ], + "repositories_removed": [], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json new file mode 100644 index 000000000..83902bea0 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_repositories_removed.json @@ -0,0 +1,55 @@ +{ + "action": "removed", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repository_selection": "selected", + "repositories_added": [], + "repositories_removed": [ + { + "id": 102, + "node_id": "R_kgDO102", + "name": "web", + "full_name": "acme/web", + "private": true + } + ], + "requester": null, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json b/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json new file mode 100644 index 000000000..a8e2512ba --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_suspend.json @@ -0,0 +1,58 @@ +{ + "action": "suspend", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + }, + "suspended_at": "2026-10-27T15:04:05Z" + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json b/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json new file mode 100644 index 000000000..79fbfa7f1 --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/installation_unsuspend.json @@ -0,0 +1,52 @@ +{ + "action": "unsuspend", + "installation": { + "id": 77, + "client_id": "Iv1.testclientid", + "account": { + "login": "acme", + "id": 555, + "type": "Organization", + "site_admin": false, + "html_url": "https://github.com/acme" + }, + "repository_selection": "selected", + "access_tokens_url": "https://api.github.com/app/installations/77/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/acme/settings/installations/77", + "app_id": 424242, + "app_slug": "acme-dashboard", + "target_id": 555, + "target_type": "Organization", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + "members": "read" + }, + "events": [], + "created_at": "2026-10-01T12:00:00.000Z", + "updated_at": "2026-10-27T12:00:00.000Z", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 101, + "node_id": "R_kgDO101", + "name": "api", + "full_name": "acme/api", + "private": true + } + ], + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/fixtures/files/github_webhooks/ping.json b/actionagent/test/fixtures/files/github_webhooks/ping.json new file mode 100644 index 000000000..5a2d6dccc --- /dev/null +++ b/actionagent/test/fixtures/files/github_webhooks/ping.json @@ -0,0 +1,20 @@ +{ + "zen": "Keep it logically awesome.", + "hook_id": 1234, + "hook": { + "type": "App", + "id": 1234, + "active": true, + "events": [ + "installation_repositories" + ], + "app_id": 424242 + }, + "sender": { + "login": "octocat", + "id": 9001, + "type": "User", + "site_admin": false, + "html_url": "https://github.com/octocat" + } +} diff --git a/actionagent/test/github_app_webhooks_test.rb b/actionagent/test/github_app_webhooks_test.rb new file mode 100644 index 000000000..680659a71 --- /dev/null +++ b/actionagent/test/github_app_webhooks_test.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "support/github_app" + +# POST /api/github_app/webhooks: GitHub App deliveries, verified by +# their signature, recorded once by X-GitHub-Delivery, and applied to the +# linked installation (or the user's connection) by GithubWebhookJob. The +# payloads are GitHub's, as recorded in test/fixtures/files/github_webhooks, +# and nothing here reaches the network. +class GithubAppWebhooksTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + include GithubAppTestHelper + + ENDPOINT = "/activeagents/api/github_app/webhooks" + SECRET = "webhook-secret-for-tests" + FIXTURES = File.expand_path("fixtures/files/github_webhooks", __dir__) + + def setup + ActionAgent::GithubInstallation.delete_all + ActionAgent::GithubConnection.delete_all + ActionAgent::GithubWebhookDelivery.delete_all + configure_github_app! + ActionAgent.github_app_webhook_secret = SECRET + end + + def teardown + reset_github_app! + ActionAgent.github_app_webhook_secret = nil + ActionAgent.github_client_id = nil + end + + test "a delivery with a valid signature is accepted and queued" do + link_installation! + + assert_enqueued_with(job: ActionAgent::GithubWebhookJob) do + deliver "installation", "installation_deleted" + end + + assert_response :accepted + assert_equal "queued", response.parsed_body["status"] + delivery = ActionAgent::GithubWebhookDelivery.sole + assert_equal [ "installation", "deleted", INSTALLATION_ID ], [ delivery.event, delivery.event_action, delivery.installation_id ] + end + + test "a bad, malformed or missing signature is refused with a 401 and nothing is recorded" do + installation = link_installation! + body = fixture("installation_deleted") + signatures = [ + sign(body, "some-other-secret"), + sign(body.sub("deleted", "suspend"), SECRET), # signed for a different body + OpenSSL::HMAC.hexdigest("SHA256", SECRET, body), # no sha256= prefix + "sha1=#{OpenSSL::HMAC.hexdigest('SHA1', SECRET, body)}", + "", + nil + ] + + signatures.each do |signature| + post ENDPOINT, params: body, headers: headers("installation", signature: signature) + + assert_response :unauthorized, signature.inspect + end + assert_no_enqueued_jobs + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + assert_nil installation.reload.removed_at + end + + test "with no webhook secret configured there is no endpoint" do + ActionAgent.github_app_webhook_secret = nil + skip "GITHUB_APP_WEBHOOK_SECRET is set in this environment" if ActionAgent.github_app_webhook_secret + + body = fixture("installation_deleted") + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET)) + + assert_response :not_found + assert_no_enqueued_jobs + end + + test "the secret falls back to GITHUB_APP_WEBHOOK_SECRET" do + ActionAgent.github_app_webhook_secret = nil + with_env("GITHUB_APP_WEBHOOK_SECRET" => "from-the-environment") do + assert_equal "from-the-environment", ActionAgent.github_app_webhook_secret + + body = fixture("ping") + post ENDPOINT, params: body, headers: headers("ping", signature: sign(body, "from-the-environment")) + assert_response :accepted + end + end + + test "is exempt from forgery protection, like the other signed endpoints" do + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + link_installation! + + deliver "installation", "installation_deleted", extra_headers: { "Sec-Fetch-Site" => "cross-site" } + + assert_response :accepted + ensure + ActionController::Base.allow_forgery_protection = original + end + + test "redelivering the same X-GitHub-Delivery changes nothing" do + installation = link_installation! + + perform_enqueued_jobs do + deliver "installation", "installation_suspend", delivery: "delivery-1" + deliver "installation", "installation_unsuspend", delivery: "delivery-2" + # GitHub's Redeliver on the earlier suspend, after the unsuspend. + deliver "installation", "installation_suspend", delivery: "delivery-1" + end + + assert_response :accepted + assert_equal "duplicate", response.parsed_body["status"] + assert_nil installation.reload.suspended_at + assert_equal 2, ActionAgent::GithubWebhookDelivery.count + end + + test "a delivery that cannot be enqueued is not recorded, so its redelivery is processed" do + link_installation! + body = fixture("installation_deleted") + + ActionAgent::GithubWebhookJob.stub(:perform_later, ->(*) { raise "queue unavailable" }) do + assert_raises(RuntimeError) do + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET), delivery: "delivery-1") + end + end + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + + deliver "installation", "installation_deleted", delivery: "delivery-1" + assert_equal "queued", response.parsed_body["status"] + end + + test "deliveries older than the retention window are pruned" do + ActionAgent::GithubWebhookDelivery.create!(delivery_id: "old", event: "installation", created_at: 8.days.ago) + ActionAgent::GithubWebhookDelivery.create!(delivery_id: "recent", event: "installation", created_at: 1.day.ago) + link_installation! + + deliver "installation", "installation_deleted", delivery: "new" + + assert_equal %w[new recent], ActionAgent::GithubWebhookDelivery.order(:delivery_id).pluck(:delivery_id) + end + + test "uninstalling the App marks the installation removed without a token mint" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api") ]) + + perform_enqueued_jobs { deliver "installation", "installation_deleted" } + + installation.reload + assert_not_nil installation.removed_at + assert_equal "removed", installation.status + assert_not installation.usable? + assert_equal [ "acme/api" ], installation.repository_names, "the selection is kept for the owner to see" + assert_not_requested :post, mint_url + + get "/activeagents/api/github_installations" + assert_equal "removed", response.parsed_body["installations"].sole["status"] + end + + test "suspending and unsuspending the App toggles suspended_at" do + installation = link_installation! + + perform_enqueued_jobs { deliver "installation", "installation_suspend" } + assert_equal Time.iso8601("2026-10-27T15:04:05Z"), installation.reload.suspended_at + assert_equal "suspended", installation.status + + perform_enqueued_jobs { deliver "installation", "installation_unsuspend" } + assert_nil installation.reload.suspended_at + assert_equal "active", installation.status + end + + test "accepting new permissions records them" do + installation = link_installation!(permissions: { "contents" => "write" }) + + perform_enqueued_jobs { deliver "installation", "installation_new_permissions_accepted" } + + assert_equal "read", installation.reload.permissions["checks"] + assert_equal "write", installation.permissions["pull_requests"] + end + + test "removing repositories on GitHub drops them from the owner's selection" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api"), repo_row(102, "acme/web") ]) + + perform_enqueued_jobs { deliver "installation_repositories", "installation_repositories_removed" } + + assert_equal [ "acme/api" ], installation.reload.repository_names + end + + test "adding repositories on GitHub keeps the selection, and the picker lists the new one" do + installation = link_installation!(repositories: [ repo_row(101, "acme/api") ]) + + perform_enqueued_jobs { deliver "installation_repositories", "installation_repositories_added" } + + assert_equal [ "acme/api" ], installation.reload.repository_names, "nothing is selected for the owner" + assert_equal "selected", installation.repository_selection + + # The picker asks GitHub what the installation reaches when it opens. + stub_mint + stub_installation_repositories([ repo_payload(101, "acme/api"), repo_payload(103, "acme/docs") ]) + get "/activeagents/api/github_installations/#{installation.id}/repositories" + listed = response.parsed_body["repositories"].to_h { |repo| [ repo["full_name"], repo["selected"] ] } + assert_equal({ "acme/api" => true, "acme/docs" => false }, listed) + end + + test "a delivery for an installation no owner linked changes nothing" do + installation = link_installation!(installation_id: INSTALLATION_ID + 100) + + perform_enqueued_jobs do + deliver "installation", "installation_deleted" + deliver "installation", "installation_created" + deliver "installation_repositories", "installation_repositories_removed" + end + + assert_response :accepted + assert_nil installation.reload.removed_at + assert_equal 1, ActionAgent::GithubInstallation.count, "a new installation is linked only from Settings" + end + + test "events the dashboard does not handle are accepted and ignored" do + link_installation! + + deliver "ping", "ping" + assert_response :accepted + assert_equal "ignored", response.parsed_body["status"] + + deliver "push", "installation_deleted" + assert_equal "ignored", response.parsed_body["status"] + + assert_no_enqueued_jobs + assert_equal 0, ActionAgent::GithubWebhookDelivery.count + end + + test "a handled event without a delivery id or a JSON object body is refused" do + body = fixture("installation_deleted") + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET), delivery: nil) + assert_response :bad_request + + post ENDPOINT, params: "[1]", headers: headers("installation", signature: sign("[1]", SECRET)) + assert_response :bad_request + + assert_no_enqueued_jobs + end + + test "a form-encoded delivery is read from its payload field" do + installation = link_installation! + body = URI.encode_www_form(payload: fixture("installation_deleted")) + + perform_enqueued_jobs do + post ENDPOINT, params: body, headers: headers("installation", signature: sign(body, SECRET)) + .merge("Content-Type" => "application/x-www-form-urlencoded") + end + + assert_response :accepted + assert_not_nil installation.reload.removed_at + end + + test "revoking the App's authorization deletes the user's connection token when connections go through the App" do + ActionAgent.github_client_id = CLIENT_ID # the OAuth connection flow authorizes through the App + connection = connect!(github_user_id: GITHUB_USER_ID) + other = connect!(github_user_id: GITHUB_USER_ID + 1, user_id: 2) + + perform_enqueued_jobs { deliver "github_app_authorization", "github_app_authorization_revoked" } + + assert_not ActionAgent::GithubConnection.exists?(connection.id) + assert ActionAgent::GithubConnection.exists?(other.id) + end + + test "revoking the App's authorization leaves a connection made through a separate OAuth App" do + ActionAgent.github_client_id = "Ov23.separate-oauth-app" + connection = connect!(github_user_id: GITHUB_USER_ID) + + perform_enqueued_jobs { deliver "github_app_authorization", "github_app_authorization_revoked" } + + assert ActionAgent::GithubConnection.exists?(connection.id) + end + + private + + def fixture(name) + File.read(File.join(FIXTURES, "#{name}.json")) + end + + def sign(body, secret) + "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + end + + def headers(event, signature:, delivery: SecureRandom.uuid) + { + "Content-Type" => "application/json", + "X-GitHub-Event" => event, + "X-GitHub-Delivery" => delivery, + "X-GitHub-Hook-Installation-Target-Type" => "integration", + "X-Hub-Signature-256" => signature + }.compact + end + + def deliver(event, name, delivery: SecureRandom.uuid, extra_headers: {}) + body = fixture(name) + post ENDPOINT, params: body, headers: headers(event, signature: sign(body, SECRET), delivery: delivery).merge(extra_headers) + end + + # Without validation: a single-user dashboard allows one connection, and + # this stands in for two owners' connections. + def connect!(github_user_id:, user_id: nil) + connection = ActionAgent::GithubConnection.new( + access_token: "gho_#{SecureRandom.hex(18)}", github_user_id: github_user_id, login: "octocat", user_id: user_id + ) + connection.save!(validate: false) + connection + end + + def with_env(values) + previous = values.keys.to_h { |key| [ key, ENV[key] ] } + values.each { |key, value| ENV[key] = value } + yield + ensure + previous.each { |key, value| ENV[key] = value } + end +end diff --git a/actionagent/test/install_generator_test.rb b/actionagent/test/install_generator_test.rb index 89ae9f845..f4a22c08d 100644 --- a/actionagent/test/install_generator_test.rb +++ b/actionagent/test/install_generator_test.rb @@ -439,6 +439,29 @@ class ActionAgentInstallGeneratorTest < Rails::Generators::TestCase connection&.drop_table("#{prefix}draft_pull_requests", if_exists: true) end + # Under a probe prefix, so the result can be compared with the dummy's own + # table. + test "the webhook deliveries migration builds the table the dummy schema has, with a unique delivery id" do + run_generator [ "--skip-routes" ] + assert_migration "db/migrate/create_active_agent_github_webhook_deliveries.rb" + connection = ActiveRecord::Base.connection + prefix = "wh_probe_" + ActionAgent.table_name_prefix = prefix + + run_migration("create_active_agent_github_webhook_deliveries", :CreateActiveAgentGithubWebhookDeliveries) + + shape = ->(table) { connection.columns(table).to_h { |column| [ column.name, [ column.sql_type, column.null, column.default ] ] } } + assert_equal shape.call("active_agent_github_webhook_deliveries"), shape.call("#{prefix}github_webhook_deliveries") + assert connection.index_exists?("#{prefix}github_webhook_deliveries", :delivery_id, unique: true) + assert connection.index_exists?("#{prefix}github_webhook_deliveries", :created_at) + + run_migration("create_active_agent_github_webhook_deliveries", :CreateActiveAgentGithubWebhookDeliveries, :down) + assert_not connection.table_exists?("#{prefix}github_webhook_deliveries") + ensure + ActionAgent.table_name_prefix = "active_agent_" + connection&.drop_table("#{prefix}github_webhook_deliveries", if_exists: true) + end + test "a missing numbered template directory emits nothing" do ActionAgent::InstallGenerator.numbered_migrations_path = File.join(destination_root, "no-such-directory") diff --git a/docs/framework/dashboard.md b/docs/framework/dashboard.md index b0866aee6..f03675613 100644 --- a/docs/framework/dashboard.md +++ b/docs/framework/dashboard.md @@ -1084,7 +1084,8 @@ with: installation that already exists is reconfigured - repository permissions Contents (read and write), Pull requests (read and write) and Metadata (read), and the organization permission Members (read) -- no webhook, and no Workflows, Administration or Secrets permission +- no Workflows, Administration or Secrets permission +- optionally, a webhook (see [GitHub App webhooks](#github-app-webhooks)) Then configure it, and restart the dashboard: @@ -1137,6 +1138,44 @@ as it does again once a suspended installation is unsuspended. An App uninstalled from an account comes back as a new installation when it is installed again; unlink the old one. +#### GitHub App webhooks + +Without a webhook, the dashboard learns that an installation was removed or +suspended only when GitHub next refuses a token. With one, it learns when it +happens. On the App's settings page on GitHub, set the **Webhook URL** to +`/api/github_app/webhooks` (for example +`https://example.com/activeagents/api/github_app/webhooks`), give it a +**Webhook secret**, check **Active**, and subscribe it to the +**Installation** and **Installation repositories** events (GitHub also +sends the App's authorization revocations). Then configure the secret: + +```ruby +ActionAgent.configure do |config| + config.github_app_webhook_secret = Rails.application.credentials.dig(:github_app, :webhook_secret) +end +``` + +Unset, it falls back to `GITHUB_APP_WEBHOOK_SECRET`, and with neither the +endpoint answers 404. A delivery is accepted only when its +`X-Hub-Signature-256` is the HMAC-SHA256 of its body under that secret (a +401 otherwise). The endpoint answers 202 straight away and applies the +delivery in `ActionAgent::GithubWebhookJob`, so the host app needs a running +Active Job backend: + +| Event | Action | Effect | +|-------|--------|--------| +| `installation` | `deleted` | the installation is marked removed | +| `installation` | `suspend` / `unsuspend` | it is marked suspended, and the mark is cleared | +| `installation` | `new_permissions_accepted` | its permissions are recorded | +| `installation_repositories` | `removed` | the removed repositories leave the owner's selection | +| `installation_repositories` | `added` | nothing to store: the repository picker lists them the next time it opens | +| `github_app_authorization` | `revoked` | the user's stored OAuth connection is deleted, when `github_client_id` is the App's client id (a connection made through a separate OAuth App is not this grant) | + +A delivery about an installation no owner linked changes nothing, and other +events are answered 202 and ignored. Each `X-GitHub-Delivery` is recorded +(in `github_webhook_deliveries`, for seven days) before its job is enqueued, +so a redelivery of the same delivery is a no-op. + ### An OAuth connection Register a [GitHub OAuth app](https://github.com/settings/developers) whose diff --git a/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb b/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb new file mode 100644 index 000000000..ee2cd45ee --- /dev/null +++ b/test/dummy/db/migrate/029_create_active_agent_github_webhook_deliveries.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# The GitHub App webhook deliveries the dashboard accepted, kept briefly so a +# redelivery of the same X-GitHub-Delivery is a no-op (see +# ActionAgent::GithubWebhookDelivery). Nothing here is a credential or a +# payload. +class CreateActiveAgentGithubWebhookDeliveries < ActiveRecord::Migration[7.2] + def change + prefix = ActionAgent.table_name_prefix + + create_table "#{prefix}github_webhook_deliveries" do |t| + # GitHub's X-GitHub-Delivery, a GUID. Unique: a second insert of the + # same delivery is how a redelivery is recognized. + t.string :delivery_id, null: false, limit: 64 + t.string :event, null: false, limit: 64 + t.string :event_action, limit: 64 + t.bigint :installation_id + t.datetime :created_at, null: false + t.index :delivery_id, unique: true, name: "index_#{prefix}webhook_deliveries_on_delivery" + t.index :created_at, name: "index_#{prefix}webhook_deliveries_on_created_at" + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index acb2e4721..0bf38d458 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 28) do +ActiveRecord::Schema[8.0].define(version: 29) do create_table "active_agent_agent_contexts", force: :cascade do |t| t.string "action_name", null: false t.string "agent_name", null: false @@ -419,6 +419,16 @@ t.index [ "user_id" ], name: "index_active_agent_github_installations_on_user" end + create_table "active_agent_github_webhook_deliveries", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "delivery_id", limit: 64, null: false + t.string "event", limit: 64, null: false + t.string "event_action", limit: 64 + t.bigint "installation_id" + t.index [ "created_at" ], name: "index_active_agent_webhook_deliveries_on_created_at" + t.index [ "delivery_id" ], name: "index_active_agent_webhook_deliveries_on_delivery", unique: true + end + create_table "active_agent_input_requests", force: :cascade do |t| t.bigint "account_id" t.text "answer"