Skip to content

Commit 6ffd9db

Browse files
akabirufoxweb
andcommitted
Match GitHub/GitLab records by their unique webhook URL
GitLab's per-project iid repeats across repositories, so matching pull requests, merge requests and issues by id linked webhooks to the wrong work packages. Resolve every lookup by the globally unique webhook URL and enforce it with a database index. This also lets partial pull requests created from issue comments be promoted in place rather than duplicated. Co-authored-by: Aleksey Kurepin <lesha@kurepin.com>
1 parent 6f8e366 commit 6ffd9db

16 files changed

Lines changed: 228 additions & 156 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
# The webhook URL is the only globally unique identifier for a pull request,
4+
# merge request or issue: per-project sequential numbers (GitLab's iid) repeat
5+
# across repositories. Enforce that uniqueness at the database level so a
6+
# missed lookup site or a concurrent webhook can never split one entity across
7+
# two rows. Surviving duplicates are folded into the most recent row first;
8+
# none are expected, since every write path already keys on the URL.
9+
class AddUniqueIndexToIntegrationHtmlUrls < ActiveRecord::Migration[8.1]
10+
ENTITIES = {
11+
"github_pull_requests" => {
12+
url_column: "github_html_url",
13+
work_packages_join: { table: "github_pull_requests_work_packages", foreign_key: "github_pull_request_id" },
14+
children: { "github_check_runs" => "github_pull_request_id", "deploy_status_checks" => "github_pull_request_id" }
15+
},
16+
"gitlab_issues" => {
17+
url_column: "gitlab_html_url",
18+
work_packages_join: { table: "gitlab_issues_work_packages", foreign_key: "gitlab_issue_id" },
19+
children: {}
20+
},
21+
"gitlab_merge_requests" => {
22+
url_column: "gitlab_html_url",
23+
work_packages_join: { table: "gitlab_merge_requests_work_packages", foreign_key: "gitlab_merge_request_id" },
24+
children: { "gitlab_pipelines" => "gitlab_merge_request_id" }
25+
}
26+
}.freeze
27+
28+
def up
29+
ENTITIES.each do |table, config|
30+
say_with_time "Deduplicating #{table} by #{config[:url_column]}" do
31+
fold_duplicates(table, config)
32+
end
33+
add_index table, config[:url_column], unique: true
34+
end
35+
end
36+
37+
def down
38+
ENTITIES.each do |table, config|
39+
remove_index table, config[:url_column]
40+
end
41+
end
42+
43+
private
44+
45+
def fold_duplicates(table, config)
46+
move_work_packages_to_survivor(table, config)
47+
repoint_children(table, config)
48+
delete_duplicates(table, config[:url_column])
49+
end
50+
51+
# Move the work packages of doomed rows onto the survivor, skipping links it already has.
52+
def move_work_packages_to_survivor(table, config)
53+
join_table = config[:work_packages_join][:table]
54+
foreign_key = config[:work_packages_join][:foreign_key]
55+
duplicates = duplicates_sql(table, config[:url_column])
56+
57+
execute <<~SQL.squish
58+
INSERT INTO #{join_table} (#{foreign_key}, work_package_id)
59+
SELECT DISTINCT duplicates.keep_id, links.work_package_id
60+
FROM #{join_table} links
61+
JOIN (#{duplicates}) duplicates
62+
ON duplicates.dup_id = links.#{foreign_key}
63+
WHERE NOT EXISTS (
64+
SELECT 1 FROM #{join_table} existing
65+
WHERE existing.#{foreign_key} = duplicates.keep_id
66+
AND existing.work_package_id = links.work_package_id
67+
)
68+
SQL
69+
execute <<~SQL.squish
70+
DELETE FROM #{join_table} links
71+
USING (#{duplicates}) duplicates
72+
WHERE duplicates.dup_id = links.#{foreign_key}
73+
SQL
74+
end
75+
76+
def repoint_children(table, config)
77+
config[:children].each do |child_table, foreign_key|
78+
execute <<~SQL.squish
79+
UPDATE #{child_table} child
80+
SET #{foreign_key} = duplicates.keep_id
81+
FROM (#{duplicates_sql(table, config[:url_column])}) duplicates
82+
WHERE duplicates.dup_id = child.#{foreign_key}
83+
SQL
84+
end
85+
end
86+
87+
def delete_duplicates(table, url_column)
88+
execute <<~SQL.squish
89+
DELETE FROM #{table} target
90+
USING (#{duplicates_sql(table, url_column)}) duplicates
91+
WHERE duplicates.dup_id = target.id
92+
SQL
93+
end
94+
95+
# Maps every duplicate row to the surviving (highest id) row sharing its URL.
96+
def duplicates_sql(table, url_column)
97+
<<~SQL.squish
98+
SELECT row.id AS dup_id, survivors.keep_id
99+
FROM #{table} row
100+
JOIN (
101+
SELECT #{url_column} AS url, MAX(id) AS keep_id
102+
FROM #{table}
103+
GROUP BY #{url_column}
104+
HAVING COUNT(*) > 1
105+
) survivors ON survivors.url = row.#{url_column}
106+
WHERE row.id <> survivors.keep_id
107+
SQL
108+
end
109+
end

modules/github_integration/app/models/github_pull_request.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,11 @@ class GithubPullRequest < ApplicationRecord
6060

6161
scope :without_work_package, -> { where.missing(:work_packages) }
6262

63-
def self.find_by_github_identifiers(id: nil, url: nil, initialize: false)
64-
raise ArgumentError, "needs an id and an url" if id.nil? || url.blank?
63+
def self.find_by_github_identifiers(url:, id: nil, initialize: false)
64+
raise ArgumentError, "needs an url" if url.blank?
6565

66-
found = find_by(github_id: id, github_html_url: url)
67-
68-
if found
69-
found
70-
elsif initialize
71-
new(github_id: id, github_html_url: url)
72-
end
66+
found = find_by(github_html_url: url)
67+
found || (new(github_id: id, github_html_url: url) if initialize)
7368
end
7469

7570
def visible?(user = User.current)

modules/github_integration/lib/open_project/github_integration/notification_handler/pull_request.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ def work_packages_to_comment_on(action, work_packages, already_referenced)
7373
end
7474

7575
def already_referenced_work_packages(payload)
76-
pull_request = GithubPullRequest.find_by(github_id: payload.pull_request.id,
77-
github_html_url: payload.pull_request.html_url)
76+
pull_request = GithubPullRequest.find_by(github_html_url: payload.pull_request.html_url)
7877

7978
pull_request&.work_packages.to_a
8079
end

modules/github_integration/spec/lib/open_project/github_integration/notification_handler/issue_comment_spec.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,10 @@
9494
process
9595
expect(upsert_partial_pull_request_service)
9696
.to have_received(:call) do |received_payload, work_packages:|
97-
expect(received_payload.to_h)
98-
.to eql payload
99-
expect(work_packages)
100-
.to contain_exactly(work_package)
97+
expect(received_payload.to_h)
98+
.to eql payload
99+
expect(work_packages)
100+
.to contain_exactly(work_package)
101101
end
102102
end
103103
end

modules/github_integration/spec/lib/open_project/github_integration/notification_handler/pull_request_spec.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@
209209
let!(:other_work_package) { create(:work_package) }
210210
let(:pr_body) { "Mentioning OP##{work_package.id} and OP##{other_work_package.id}" }
211211

212-
# NOTE: strange behavior, needs to clarification. it received two work_packages instead one.
213212
it "adds a comment only for the other_work_package" do
214213
process
215214
expect(handler_instance).to have_received(:comment_on_referenced_work_packages).with(

modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,19 @@
140140
state: "closed")
141141
end
142142

143-
it "doesn't update the github pull request" do
144-
expect { upsert }.not_to change(github_pull_request, :reload)
143+
it "promotes the partial pull request in place instead of creating a duplicate" do
144+
expect { upsert }.to change { github_pull_request.reload.state }.from("closed").to("open")
145+
146+
expect(GithubPullRequest.where(github_html_url:).count).to eq(1)
147+
expect(github_pull_request).to have_attributes(
148+
github_id:,
149+
number: 5,
150+
title: "The PR title",
151+
body: "The PR body",
152+
github_html_url:,
153+
github_updated_at: Time.zone.parse("20210409T12:13:14Z"),
154+
repository: "test_user/repo"
155+
)
145156
end
146157
end
147158

modules/github_integration/spec/models/github_pull_request_spec.rb

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -91,68 +91,35 @@
9191
github_html_url: github_url)
9292
end
9393

94-
context "when the github_id attribute matches" do
95-
it "finds by github_id" do
96-
expect(described_class.find_by_github_identifiers(id: pull_request.github_id, url: github_url))
97-
.to eql pull_request
98-
end
99-
end
100-
101-
context "when the github_html_url attribute matches" do
102-
it "finds by github_html_url" do
103-
expect(described_class.find_by_github_identifiers(id: pull_request.github_id, url: pull_request.github_html_url))
104-
.to eql pull_request
105-
end
94+
it "raises an ArgumentError when no url is provided" do
95+
expect { described_class.find_by_github_identifiers(id: github_id, url: nil) }
96+
.to raise_error(ArgumentError, "needs an url")
10697
end
10798

108-
context "when the provided github_id does not match" do
109-
it "returns nothing" do
99+
context "when the url matches" do
100+
it "returns the pull request regardless of the id" do
110101
expect(described_class.find_by_github_identifiers(id: pull_request.github_id + 1, url: github_url))
111-
.to be_nil
112-
end
113-
end
114-
115-
context "when the provided github_html_url does not match" do
116-
it "returns nothing" do
117-
expect(described_class.find_by_github_identifiers(id: pull_request.github_id, url: "#{pull_request.github_html_url}zzzz"))
118-
.to be_nil
102+
.to eql pull_request
119103
end
120104
end
121105

122-
context "when neither match" do
123-
it "returns nothing" do
124-
expect(described_class.find_by_github_identifiers(id: pull_request.github_id + 1,
106+
context "when the url does not match" do
107+
it "returns nothing even when the id matches" do
108+
expect(described_class.find_by_github_identifiers(id: pull_request.github_id,
125109
url: "#{pull_request.github_html_url}zzzz"))
126110
.to be_nil
127111
end
128112
end
129113

130-
context "when the provided github_html_url does match but the github_id does not" do
131-
it "returns nothing" do
132-
expect(described_class.find_by_github_identifiers(id: pull_request.github_id + 1,
133-
url: pull_request.github_html_url))
134-
.to be_nil
135-
end
136-
end
137-
138-
context "when neither match but initialize is true" do
114+
context "when the url does not match but initialize is true" do
139115
subject(:finder) do
140116
described_class.find_by_github_identifiers(id: pull_request.github_id + 1,
141117
url: "#{pull_request.github_html_url}zzzz",
142118
initialize: true)
143119
end
144120

145-
it "returns a pull reqeust" do
146-
expect(finder)
147-
.to be_a(described_class)
148-
end
149-
150-
it "returns a new record" do
151-
expect(finder)
152-
.to be_new_record
153-
end
154-
155-
it "has the provided attributes initialized" do
121+
it "returns a new record with the provided attributes" do
122+
expect(finder).to be_a(described_class).and be_new_record
156123
expect(finder.attributes.compact)
157124
.to eql("github_id" => pull_request.github_id + 1,
158125
"github_html_url" => "#{pull_request.github_html_url}zzzz")

modules/gitlab_integration/app/models/gitlab_issue.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,11 @@ class GitlabIssue < ApplicationRecord
5252

5353
scope :without_work_package, -> { where.missing(:work_packages) }
5454

55-
def self.find_by_gitlab_identifiers(id: nil, url: nil, initialize: false)
56-
raise ArgumentError, "needs an id and an url" if id.nil? || url.blank?
55+
def self.find_by_gitlab_identifiers(url:, id: nil, initialize: false)
56+
raise ArgumentError, "needs an url" if url.blank?
5757

58-
found = find_by(gitlab_id: id, gitlab_html_url: url)
59-
60-
if found
61-
found
62-
elsif initialize
63-
new(gitlab_id: id, gitlab_html_url: url)
64-
end
58+
found = find_by(gitlab_html_url: url)
59+
found || (new(gitlab_id: id, gitlab_html_url: url) if initialize)
6560
end
6661

6762
def partial?

modules/gitlab_integration/app/models/gitlab_merge_request.rb

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,11 @@ class GitlabMergeRequest < ApplicationRecord
5555

5656
scope :without_work_package, -> { where.missing(:work_packages) }
5757

58-
def self.find_by_gitlab_identifiers(id: nil, url: nil, initialize: false)
59-
raise ArgumentError, "needs an id and an url" if id.nil? || url.blank?
58+
def self.find_by_gitlab_identifiers(url:, id: nil, initialize: false)
59+
raise ArgumentError, "needs an url" if url.blank?
6060

61-
found = find_by(gitlab_id: id, gitlab_html_url: url)
62-
63-
if found
64-
found
65-
elsif initialize
66-
new(gitlab_id: id, gitlab_html_url: url)
67-
end
61+
found = find_by(gitlab_html_url: url)
62+
found || (new(gitlab_id: id, gitlab_html_url: url) if initialize)
6863
end
6964

7065
##

modules/gitlab_integration/lib/open_project/gitlab_integration/notification_handler/issue_hook.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ def generate_notes(_payload)
6868
def gitlab_issue
6969
return @gitlab_issue if defined?(@gitlab_issue)
7070

71-
@gitlab_issue = GitlabIssue.find_by(gitlab_id: payload.object_attributes.iid,
72-
gitlab_html_url: payload.object_attributes.url)
71+
@gitlab_issue = GitlabIssue.find_by(gitlab_html_url: payload.object_attributes.url)
7372
end
7473

7574
def upsert_issue(work_packages)

0 commit comments

Comments
 (0)