Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/onebox/engine/github_pull_request_onebox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def match
def data
result = raw(github_auth_header(match[:org])).clone
result["link"] = link
result["pr_status"] = fetch_pr_status(result)

created_at = Time.parse(result["created_at"])
result["created_at"] = created_at.strftime("%I:%M%p - %d %b %y %Z")
Expand Down Expand Up @@ -111,6 +112,40 @@ def load_json(url)
::MultiJson.load(
URI.parse(url).open({ read_timeout: timeout }.merge(github_auth_header(match[:org]))),
)
rescue OpenURI::HTTPError => e
Rails.logger.warn("GitHub API error: #{e.io.status[0]} fetching #{url}")
raise
end

def fetch_pr_status(pr_data)
return unless SiteSetting.github_pr_status_enabled

return "merged" if pr_data["merged"]
return "closed" if pr_data["state"] == "closed"
return "draft" if pr_data["draft"]

reviews_data = load_json(url + "/reviews")
return "approved" if reviews_approved?(reviews_data)

"open"
rescue StandardError => e
Rails.logger.warn("GitHub PR status fetch error: #{e.message}")
nil
end

def reviews_approved?(reviews)
return false if reviews.blank?

states =
reviews
.reject { |r| r.dig("user", "id").nil? || %w[PENDING COMMENTED].include?(r["state"]) }
.group_by { |r| r.dig("user", "id") }
.transform_values { |rs| rs.max_by { |r| r["submitted_at"] }["state"] }
.values

return false if states.empty?

states.all? { |s| %w[APPROVED DISMISSED].include?(s) } && states.include?("APPROVED")
end
end
end
Expand Down
8 changes: 3 additions & 5 deletions lib/onebox/mixins/github_auth_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ module Mixins
module GithubAuthHeader
def github_auth_header(github_org)
return {} if SiteSetting.github_onebox_access_tokens.blank?
org_tokens =
SiteSetting.github_onebox_access_tokens.split("\n").map { |line| line.split("|") }.to_h

# Use the default token if no token is found for the org,
# this will be the token that used to be stored in the old
# github_onebox_access_token site setting if it was configured.
org_tokens = SiteSetting.github_onebox_access_tokens.split("\n").to_h { _1.split("|") }

token = org_tokens[github_org] || org_tokens["default"]

return {} if token.blank?

{ "Authorization" => "Bearer #{token}" }
Expand Down
2 changes: 1 addition & 1 deletion lib/onebox/templates/githubpullrequest.mustache
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="github-row" data-github-private-repo="{{is_private}}">
<div class="github-row{{#pr_status}} --gh-status-{{pr_status}}{{/pr_status}}" data-github-private-repo="{{is_private}}">
{{#commit}}
<div class="github-icon-container" title="Commit">
<svg width="60" height="60" class="github-icon" viewBox="0 0 16 16" version="1.1" aria-hidden="true"><path fill-rule="evenodd" d="M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z"></path></svg>
Expand Down
3 changes: 3 additions & 0 deletions plugins/chat/app/jobs/regular/chat/process_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ def execute(args = {})
# we dont process mentions when creating/updating message so we always have to do it
chat_message.upsert_mentions

# extract external links for webhook-based rebaking
::Chat::MessageLink.extract_from(chat_message)

# notifier should be idempotent and not re-notify
if args[:edit_timestamp]
::Chat::Notifier.new(chat_message, args[:edit_timestamp]).notify_edit
Expand Down
72 changes: 72 additions & 0 deletions plugins/chat/app/models/chat/message_link.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Chat
class MessageLink < ActiveRecord::Base
self.table_name = "chat_message_links"

belongs_to :chat_message, class_name: "Chat::Message"

validates :url, presence: true, length: { maximum: 500 }

def self.extract_from(message)
return if message.blank? || message.deleted_at.present?

current_urls = []

PrettyText
.extract_links(message.cooked)
.map { |link| UrlHelper.relaxed_parse(link.url) }
.compact
.reject { |uri| uri.scheme == "mailto" }
.uniq
.each do |parsed|
url = parsed.to_s[0...500]
next if parsed.host.blank? || parsed.host.length > 100

current_urls << url
upsert_link(message, url)
end

cleanup_entries(message, current_urls)
end

def self.upsert_link(message, url)
sql = <<~SQL
INSERT INTO chat_message_links (chat_message_id, url, created_at, updated_at)
VALUES (:chat_message_id, :url, :now, :now)
ON CONFLICT (chat_message_id, url) DO NOTHING
SQL

DB.exec(sql, chat_message_id: message.id, url: url, now: Time.current)
end

def self.cleanup_entries(message, current_urls)
if current_urls.present?
where(
"chat_message_id = :id AND url NOT IN (:urls)",
id: message.id,
urls: current_urls,
).delete_all
else
where(chat_message_id: message.id).delete_all
end
end
end
end

# == Schema Information
#
# Table name: chat_message_links
#
# id :bigint not null, primary key
# url :string(500) not null
# created_at :datetime not null
# updated_at :datetime not null
# chat_message_id :bigint not null
#
# Indexes
#
# index_chat_message_links_on_chat_message_id (chat_message_id)
# index_chat_message_links_on_url (url)
# unique_chat_message_links (chat_message_id,url) UNIQUE
#
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

class CreateChatMessageLinks < ActiveRecord::Migration[7.2]
def change
create_table :chat_message_links do |t|
t.bigint :chat_message_id, null: false
t.string :url, null: false, limit: 500
t.timestamps
end

add_index :chat_message_links, :chat_message_id
add_index :chat_message_links, :url
add_index :chat_message_links,
%i[chat_message_id url],
unique: true,
name: "unique_chat_message_links"
end
end
8 changes: 8 additions & 0 deletions plugins/chat/spec/jobs/regular/chat/process_message_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@
chat_message.destroy
expect { described_class.new.execute(chat_message_id: chat_message.id) }.not_to raise_exception
end

it "extracts links from the message" do
described_class.new.execute(chat_message_id: chat_message.id)

link = Chat::MessageLink.find_by(chat_message_id: chat_message.id)
expect(link).to be_present
expect(link.url).to eq("https://discourse.org/team")
end
end
115 changes: 115 additions & 0 deletions plugins/chat/spec/models/chat/message_link_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# frozen_string_literal: true

RSpec.describe Chat::MessageLink do
fab!(:user)
fab!(:channel, :chat_channel)

describe ".extract_from" do
it "extracts external links from a chat message" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "Check out https://github.com/discourse/discourse/pull/123",
)
message.update!(cooked: PrettyText.cook(message.message))

Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(1)
link = Chat::MessageLink.first
expect(link.url).to eq("https://github.com/discourse/discourse/pull/123")
expect(link.chat_message_id).to eq(message.id)
end

it "extracts multiple links from a message" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "PRs: https://github.com/org/repo/pull/1 and https://github.com/org/repo/pull/2",
)
message.update!(cooked: PrettyText.cook(message.message))

Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(2)
urls = Chat::MessageLink.pluck(:url)
expect(urls).to contain_exactly(
"https://github.com/org/repo/pull/1",
"https://github.com/org/repo/pull/2",
)
end

it "removes links that no longer exist in the message" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "Check out https://github.com/org/repo/pull/1",
)
message.update!(cooked: PrettyText.cook(message.message))
Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(1)

message.update!(
message: "Check out https://github.com/org/repo/pull/2",
cooked: PrettyText.cook("Check out https://github.com/org/repo/pull/2"),
)
Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(1)
expect(Chat::MessageLink.first.url).to eq("https://github.com/org/repo/pull/2")
end

it "does not extract mailto links" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "Email me at mailto:[email protected]",
)
message.update!(cooked: PrettyText.cook(message.message))

Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(0)
end

it "does nothing for deleted messages" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "https://github.com/org/repo/pull/1",
deleted_at: Time.current,
)
message.update!(cooked: PrettyText.cook(message.message))

Chat::MessageLink.extract_from(message)

expect(Chat::MessageLink.count).to eq(0)
end

it "is idempotent" do
message =
Fabricate(
:chat_message,
chat_channel: channel,
user: user,
message: "https://github.com/org/repo/pull/1",
)
message.update!(cooked: PrettyText.cook(message.message))

3.times { Chat::MessageLink.extract_from(message) }

expect(Chat::MessageLink.count).to eq(1)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module DiscourseGithub
class WebhooksController < ::ApplicationController
requires_plugin "discourse-github"

skip_before_action :check_xhr
skip_before_action :verify_authenticity_token
skip_before_action :redirect_to_login_if_required

def github
return head :forbidden unless verify_signature

event = request.headers["X-GitHub-Event"]
return head :ok if %w[pull_request pull_request_review].exclude?(event)

pr_url = params.dig(:pull_request, :html_url)
return head :ok if pr_url.blank?

Jobs.enqueue(:rebake_github_pr_posts, pr_url: pr_url)

head :ok
end

private

def verify_signature
secret = SiteSetting.github_webhook_secret
return false if secret.blank?

signature = request.headers["X-Hub-Signature-256"]
return false if signature.blank?

request.body.rewind
body = request.body.read
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, body)

ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

module Jobs
class RebakeGithubPrPosts < ::Jobs::Base
sidekiq_options queue: "low"

def execute(args)
pr_url = args[:pr_url]
return if pr_url.blank?

rebake_posts(pr_url)
rebake_chat_messages(pr_url) if defined?(Chat)
end

private

def rebake_posts(pr_url)
post_ids =
TopicLink
.where(url: pr_url)
.or(TopicLink.where("url LIKE ?", "#{pr_url}%"))
.select(:post_id)

Post
.where(id: post_ids)
.find_each do |post|
next unless has_github_pr_onebox?(post.cooked, pr_url)
post.rebake!(invalidate_oneboxes: true, priority: :low)
end
end

def rebake_chat_messages(pr_url)
return unless defined?(Chat::MessageLink)

message_ids =
Chat::MessageLink
.where(url: pr_url)
.or(Chat::MessageLink.where("url LIKE ?", "#{pr_url}%"))
.select(:chat_message_id)

Chat::Message
.where(id: message_ids)
.find_each do |message|
next unless has_github_pr_onebox?(message.cooked, pr_url)
message.rebake!(invalidate_oneboxes: true, priority: :low)
end
end

def has_github_pr_onebox?(cooked, pr_url)
# quick & dirty check to avoid doing unnecessary rebakes
cooked.present? && cooked.include?("onebox") && cooked.include?(pr_url)
end
end
end
Loading
Loading