discourse/plugins/discourse-github/spec/requests/webhooks_controller_spec.rb
Régis Hanol f3680621bc
FEATURE: GitHub PR live status icon (#36313)
I wanted a better way to show a live/up-to-date status of a PR than what
the https://github.com/discourse/github-status-theme provided.

First, I wanted it to work on any PR, including ones in private
repositories.

Second, I wanted to include the approved status, which requires a 2nd
call to GitHub's API.

Third, I wanted something that was less "intrusive" than the
"shield-like" status, so I opted out to use the icons & color that
GitHub uses (but maybe that's a bad idea).

After a first PoC that was a dump proxy to handle authentication for private repositories, the idea to use org webhooks was raised and turned out to be much lighter and easier than figuring out a proper caching strategy.

So this adds support for receiving `pull_request` and `pull_request_review` webhooks events to schedule a background job that will rebake posts and chat messages that have a link to the mentioned PR.

The GitHub PR onebox has been updated to query and parse the review state so we can have a better icon.

This also adds the `Chat::MessageLink` model (that is the #chat version of `TopicLink`) to help better keep track of links posted in chat.

**BEFORE**

<img width="1525" height="1356" alt="BEFORE"
src="https://github.com/user-attachments/assets/02f66137-9f4f-4a91-b321-73816161e204"
/>


**AFTER**

<img width="1525" height="1356" alt="AFTER"
src="https://github.com/user-attachments/assets/2164b7ef-8cd0-4cec-aea8-936a76a94fb2"
/>


Internal ref - t/17138
2025-12-04 21:42:44 +01:00

151 lines
5 KiB
Ruby

# frozen_string_literal: true
RSpec.describe DiscourseGithub::WebhooksController do
let(:webhook_secret) { "test_secret_123" }
let(:pr_url) { "https://github.com/discourse/discourse/pull/123" }
let(:pull_request_payload) do
{ action: "closed", pull_request: { html_url: pr_url, merged: true } }
end
let(:pull_request_review_payload) do
{ action: "submitted", review: { state: "approved" }, pull_request: { html_url: pr_url } }
end
def sign_payload(payload, secret)
body = payload.to_json
signature = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, body)
[body, signature]
end
before do
SiteSetting.enable_discourse_github_plugin = true
SiteSetting.github_webhook_secret = webhook_secret
end
describe "#github" do
context "when plugin is disabled" do
before { SiteSetting.enable_discourse_github_plugin = false }
it "returns 404" do
body, signature = sign_payload(pull_request_payload, webhook_secret)
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
"X-Hub-Signature-256" => signature,
}
expect(response.status).to eq(404)
end
end
context "with invalid signature" do
it "returns 403" do
body, _signature = sign_payload(pull_request_payload, webhook_secret)
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
"X-Hub-Signature-256" => "sha256=invalid",
}
expect(response.status).to eq(403)
end
end
context "with missing signature" do
it "returns 403" do
post "/discourse-github/webhooks/github",
params: pull_request_payload.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
}
expect(response.status).to eq(403)
end
end
context "with missing webhook secret setting" do
before { SiteSetting.github_webhook_secret = "" }
it "returns 403" do
body, signature = sign_payload(pull_request_payload, webhook_secret)
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
"X-Hub-Signature-256" => signature,
}
expect(response.status).to eq(403)
end
end
context "with valid signature" do
it "enqueues rebake job for pull_request event" do
body, signature = sign_payload(pull_request_payload, webhook_secret)
expect_enqueued_with(job: :rebake_github_pr_posts, args: { pr_url: pr_url }) do
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
"X-Hub-Signature-256" => signature,
}
end
expect(response.status).to eq(200)
end
it "enqueues rebake job for pull_request_review event" do
body, signature = sign_payload(pull_request_review_payload, webhook_secret)
expect_enqueued_with(job: :rebake_github_pr_posts, args: { pr_url: pr_url }) do
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request_review",
"X-Hub-Signature-256" => signature,
}
end
expect(response.status).to eq(200)
end
it "ignores other event types" do
payload = { action: "created", issue: { html_url: "https://github.com/org/repo/issues/1" } }
body, signature = sign_payload(payload, webhook_secret)
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "issues",
"X-Hub-Signature-256" => signature,
}
expect(response.status).to eq(200)
expect(Jobs::RebakeGithubPrPosts.jobs.size).to eq(0)
end
it "handles missing PR URL gracefully" do
payload = { action: "closed", pull_request: {} }
body, signature = sign_payload(payload, webhook_secret)
post "/discourse-github/webhooks/github",
params: body,
headers: {
"CONTENT_TYPE" => "application/json",
"X-GitHub-Event" => "pull_request",
"X-Hub-Signature-256" => signature,
}
expect(response.status).to eq(200)
expect(Jobs::RebakeGithubPrPosts.jobs.size).to eq(0)
end
end
end
end