Skip to content

Commit fa207b0

Browse files
authored
Merge pull request #23614 from opf/bug/stc-652-community-contribution-github-gitlab-fix-incorrect-linking-of-mr-pr-to-work-packages
Bug/STC-652: Match GitHub/GitLab records by webhook URL to fix incorrect MR/PR linking
2 parents 0467cc0 + 27bd624 commit fa207b0

27 files changed

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

modules/github_integration/app/models/github_pull_request.rb

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) the OpenProject GmbH
@@ -41,33 +43,33 @@ class GithubPullRequest < ApplicationRecord
4143
deployed: "deployed"
4244
}
4345

44-
validates_presence_of :github_html_url,
45-
:number,
46-
:repository,
47-
:state,
48-
:title,
49-
:github_updated_at
50-
validates_presence_of :body,
51-
:comments_count,
52-
:review_comments_count,
53-
:additions_count,
54-
:deletions_count,
55-
:changed_files_count,
56-
unless: :partial?
46+
validates :github_html_url,
47+
:number,
48+
:repository,
49+
:state,
50+
:title,
51+
:github_updated_at,
52+
presence: true
53+
validates :body,
54+
:comments_count,
55+
:review_comments_count,
56+
:additions_count,
57+
:deletions_count,
58+
:changed_files_count,
59+
presence: { unless: :partial? }
5760
validate :validate_labels_schema
5861

5962
scope :without_work_package, -> { where.missing(:work_packages) }
6063

61-
def self.find_by_github_identifiers(id: nil, url: nil, initialize: false)
62-
raise ArgumentError, "needs an id or an url" if id.nil? && url.blank?
63-
64-
found = where(github_id: id).or(where(github_html_url: url)).take
64+
def self.find_by_github_identifiers(url:, id: nil, initialize: false)
65+
raise ArgumentError, "needs an url" if url.blank?
6566

66-
if found
67-
found
68-
elsif initialize
69-
new(github_id: id, github_html_url: url)
70-
end
67+
# The URL matches across every payload shape (comment payloads carry no
68+
# pull request id). The id fallback covers repository renames: GitHub ids
69+
# are globally unique, so a URL miss with an id hit is the same pull
70+
# request under a new URL, which the next upsert writes back.
71+
found = find_by(github_html_url: url) || (find_by(github_id: id) if id)
72+
found || (new(github_id: id, github_html_url: url) if initialize)
7173
end
7274

7375
def visible?(user = User.current)

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) the OpenProject GmbH
@@ -71,12 +73,9 @@ def work_packages_to_comment_on(action, work_packages, already_referenced)
7173
end
7274

7375
def already_referenced_work_packages(payload)
74-
pull_request = GithubPullRequest
75-
.where(github_id: payload.pull_request.id)
76-
.or(GithubPullRequest.where(github_html_url: payload.pull_request.html_url))
77-
.take
76+
pull_request = GithubPullRequest.find_by(github_html_url: payload.pull_request.html_url)
7877

79-
pull_request&.work_packages.to_a || []
78+
pull_request&.work_packages.to_a
8079
end
8180

8281
def upsert_pull_request(payload, work_packages)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) the OpenProject GmbH

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

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) the OpenProject GmbH
@@ -40,11 +42,11 @@
4042
"action" => action,
4143
"open_project_user_id" => github_system_user.id,
4244
"pull_request" => {
43-
"id" => 123,
45+
"id" => github_id,
4446
"number" => 1,
4547
"body" => pr_body,
4648
"title" => "A PR title",
47-
"html_url" => "http://pr.url",
49+
"html_url" => github_html_url,
4850
"updated_at" => Time.current.iso8601,
4951
"state" => "open",
5052
"draft" => pr_draft,
@@ -81,7 +83,8 @@
8183
let(:pr_body) { "Mentioning OP##{work_package.id}" }
8284
let(:pr_merged) { false }
8385
let(:pr_draft) { false }
84-
let(:github_pull_request) { GithubPullRequest.find_by_github_identifiers id: 123 }
86+
let(:github_id) { 123 }
87+
let(:github_html_url) { "https://github.com/test_user/repo" }
8588

8689
before do
8790
allow(handler_instance).to receive(:comment_on_referenced_work_packages).and_return(nil)
@@ -130,8 +133,8 @@
130133
context "with a closed action" do
131134
let(:action) { "closed" }
132135
let(:comment) do
133-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
134-
data-pull-request-state="&quot;closed&quot;"></macro>).squish
136+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
137+
data-pull-request-state="&quot;closed&quot;"></macro>).squish
135138
end
136139

137140
it_behaves_like "adding a comment"
@@ -142,15 +145,15 @@
142145
let(:action) { "closed" }
143146
let(:pr_merged) { true }
144147
let(:comment) do
145-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
146-
data-pull-request-state="&quot;merged&quot;"></macro>).squish
148+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
149+
data-pull-request-state="&quot;merged&quot;"></macro>).squish
147150
end
148151

149152
it_behaves_like "adding a comment"
150153
it_behaves_like "calls the pull request upsert service"
151154

152155
context "when the work package is already known to the GithubPullRequest" do
153-
let!(:github_pull_request) { create(:github_pull_request, github_id: 123, work_packages: [work_package]) }
156+
let!(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:, work_packages: [work_package]) }
154157

155158
it_behaves_like "adding a comment"
156159

@@ -170,16 +173,17 @@
170173

171174
context "with an edited action" do
172175
let(:action) { "edited" }
176+
let(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
173177
let(:comment) do
174-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
178+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
175179
data-pull-request-state="&quot;referenced&quot;"></macro>).squish
176180
end
177181

178182
it_behaves_like "adding a comment"
179183
it_behaves_like "calls the pull request upsert service"
180184

181185
context "when a GithubPullRequest exists that is not linked to the mentioned work package yet" do
182-
let!(:github_pull_request) { create(:github_pull_request, github_id: 123) }
186+
let!(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
183187

184188
it_behaves_like "adding a comment"
185189

@@ -190,7 +194,7 @@
190194
end
191195

192196
context "when the work package is already known to the GithubPullRequest" do
193-
let!(:github_pull_request) { create(:github_pull_request, github_id: 123, work_packages: [work_package]) }
197+
let!(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:, work_packages: [work_package]) }
194198

195199
it_behaves_like "not adding a comment"
196200

@@ -201,7 +205,7 @@
201205
end
202206

203207
context "when the a work package is already known to the GithubPullRequest but another work package is new" do
204-
let!(:github_pull_request) { create(:github_pull_request, github_id: 123, work_packages: [work_package]) }
208+
let!(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:, work_packages: [work_package]) }
205209
let!(:other_work_package) { create(:work_package) }
206210
let(:pr_body) { "Mentioning OP##{work_package.id} and OP##{other_work_package.id}" }
207211

@@ -231,8 +235,9 @@
231235

232236
context "with an opened action" do
233237
let(:action) { "opened" }
238+
let(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
234239
let(:comment) do
235-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
240+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
236241
data-pull-request-state="&quot;opened&quot;"></macro>).squish
237242
end
238243

@@ -243,8 +248,9 @@
243248
context "with an opened action when the PR is a draft" do
244249
let(:action) { "opened" }
245250
let(:pr_draft) { true }
251+
let(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
246252
let(:comment) do
247-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
253+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
248254
data-pull-request-state="&quot;opened&quot;"></macro>).squish
249255
end
250256

@@ -254,8 +260,9 @@
254260

255261
context "with a ready_for_review action" do
256262
let(:action) { "ready_for_review" }
263+
let(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
257264
let(:comment) do
258-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
265+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
259266
data-pull-request-state="&quot;ready_for_review&quot;"></macro>).squish
260267
end
261268

@@ -265,8 +272,9 @@
265272

266273
context "with a reopened action" do
267274
let(:action) { "reopened" }
275+
let(:github_pull_request) { create(:github_pull_request, github_id:, github_html_url:) }
268276
let(:comment) do
269-
%(<macro class="github_pull_request" data-pull-request-id="#{github_pull_request.id}"
277+
%(<macro class="github_pull_request" data-pull-request-id="#{GithubPullRequest.last.id}"
270278
data-pull-request-state="&quot;opened&quot;"></macro>).squish
271279
end
272280

0 commit comments

Comments
 (0)