forked from release-engineering/Sync2Jira
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdownstream_pr.py
More file actions
354 lines (304 loc) · 12.4 KB
/
Copy pathdownstream_pr.py
File metadata and controls
354 lines (304 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# This file is part of sync2jira.
# Copyright (C) 2016 Red Hat, Inc.
#
# sync2jira is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# sync2jira is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with sync2jira; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110.15.0 USA
#
# Authors: Ralph Bean <rbean@redhat.com>
# Built-In Modules
import fnmatch
import logging
# 3rd Party Modules
from jira import JIRAError
from jira.client import Issue as JIRAIssue
from jira.client import ResultList
# Local Modules
import sync2jira.downstream_issue as d_issue
from sync2jira.intermediary import Issue, matcher
log = logging.getLogger("sync2jira")
def format_comment(pr, pr_suffix, client):
"""
Formats comment to link PR.
:param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from
:param String pr_suffix: Suffix to indicate what state we're transitioning too
:param jira.client.JIRA client: JIRA Client
:return: Formatted comment
:rtype: String
"""
# Find the pr.reporter's Jira user (use query= for Jira Cloud GDPR strict mode).
ret = client.search_users(query=pr.reporter)
# Loop through ret till we find a match
for user in ret:
if getattr(user, "displayName", "") == pr.reporter:
reporter = f"[~accountId:{user.accountId}]"
break
else:
reporter = pr.reporter
if "closed" in pr_suffix:
comment = f"Merge request [{pr.title}| {pr.url}] was closed."
elif "reopened" in pr_suffix:
comment = f"Merge request [{pr.title}| {pr.url}] was reopened."
elif "merged" in pr_suffix:
comment = f"Merge request [{pr.title}| {pr.url}] was merged!"
else:
comment = (
f"{reporter} mentioned this issue in "
f"merge request [{pr.title}| {pr.url}]."
)
return comment
def issue_link_exists(client, existing: JIRAIssue, pr):
"""
Checks if we've already linked this PR
:param jira.client.JIRA client: JIRA Client
:param jira.resources.Issue existing: Existing JIRA issue that was found
:param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from
:returns: True/False if the issue exists/does not exist
"""
# Query for our issue
for issue_link in client.remote_links(existing):
if issue_link.object.url == pr.url:
# Issue has already been linked
return True
return False
def comment_exists(client, existing: JIRAIssue, new_comment):
"""
Checks if new_comment exists in existing
:param jira.client.JIRA client: JIRA Client
:param jira.resources.Issue existing: Existing JIRA issue that was found
:param String new_comment: Formatted comment we're looking for
:returns: Nothing
"""
# Grab and loop over comments
comments = client.comments(existing)
for comment in comments:
if new_comment == comment.body:
# If the comment was
return True
return False
def update_jira_issue(existing, pr, client):
"""
Updates an existing JIRA issue (i.e. tags, assignee, comments etc.).
:param jira.resources.Issue existing: Existing JIRA issue that was found
:param sync2jira.intermediary.PR pr: Upstream issue we're pulling data from
:param jira.client.JIRA client: JIRA Client
:returns: Nothing
"""
# Get our updates array
updates = pr.downstream.get("pr_updates", {})
# Format and add comment to indicate PR has been linked
new_comment = format_comment(pr, pr.suffix, client)
# See if the issue_link and comment exists
exists = issue_link_exists(client, existing, pr)
comment_exist = comment_exists(client, existing, new_comment)
# Check if the comment is already there
if not exists:
if not comment_exist:
log.info(f"Added comment for PR {pr.title} on JIRA {pr.jira_key}")
client.add_comment(existing, new_comment)
# Attach remote link
remote_link = dict(url=pr.url, title=f"[PR] {pr.title}")
d_issue.attach_link(client, existing, remote_link)
# Only synchronize merge_transition for listings that opt-in
if any("merge_transition" in item for item in updates) and "merged" in pr.suffix:
log.info("Looking for new merged_transition")
update_transition(client, existing, pr, "merge_transition")
# Only synchronize link_transition for listings that opt-in
# and a link comment has been created
if (
any("link_transition" in item for item in updates)
and "mentioned" in new_comment
and not exists
):
log.info("Looking for new link_transition")
update_transition(client, existing, pr, "link_transition")
def _matches_transition_filters(transition_config, pr, existing):
"""
Check whether a transition config entry's optional filters match the
current PR and downstream JIRA issue.
Supported filters:
- ``branches``: list of glob patterns matched against ``pr.base_branch``
- ``issue_types``: list of JIRA issue type names matched against the
existing downstream issue's type
:param dict transition_config: Single pr_updates entry
:param sync2jira.intermediary.PR pr: Upstream PR
:param jira.resources.Issue existing: Existing downstream JIRA issue
:returns: True if all filters pass (or no filters are specified)
:rtype: bool
"""
branch_filters = transition_config.get("branches")
if branch_filters is not None:
if not pr.base_branch or not any(
fnmatch.fnmatch(pr.base_branch, pattern) for pattern in branch_filters
):
log.info(
"Skipping transition: branch '%s' does not match %s",
pr.base_branch,
branch_filters,
)
return False
type_filters = transition_config.get("issue_types")
if type_filters is not None:
jira_type = existing.fields.issuetype.name
if jira_type not in type_filters:
log.info(
"Skipping transition: issue type '%s' does not match %s",
jira_type,
type_filters,
)
return False
return True
def update_transition(client, existing, pr, transition_type):
"""
Helper function to update the transition of a downstream JIRA issue.
Applies optional ``branches`` and ``issue_types`` filters from the
pr_updates config entry before executing the transition.
:param jira.client.JIRA client: JIRA client
:param jira.resource.Issue existing: Existing JIRA issue
:param sync2jira.intermediary.PR pr: Upstream issue
:param string transition_type: Transition type (link vs merged)
:returns: Nothing
"""
pr_updates = pr.downstream.get("pr_updates")
if not pr_updates:
return
for entry in pr_updates:
if transition_type not in entry:
continue
if not _matches_transition_filters(entry, pr, existing):
continue
d_issue.change_status(client, existing, entry[transition_type], pr)
log.info(f"Updated {transition_type} for issue {pr.title}")
return
log.info(
"No matching %s entry for PR %s (branch=%s)",
transition_type,
pr.title,
pr.base_branch,
)
def sync_with_jira(pr, config):
"""
Attempts to sync an upstream PR with JIRA (i.e. by finding
an existing issue).
:param sync2jira.intermediary.PR/Issue pr: PR or Issue object
:param Dict config: Config dict
:returns: Nothing
"""
log.info("[PR] Considering upstream %s, %s", pr.url, pr.title)
# Return if testing
if config["sync2jira"]["testing"]:
log.info("Testing flag is true. Skipping actual update.")
return
# Check if we should create issues for PRs without JIRA keys
create_pr_issue = pr.downstream.get("create_pr_issue", False)
if not pr.match and not create_pr_issue:
log.info(f"[PR] No match found for {pr.title}")
return
# Create a client connection for this issue
client = d_issue.get_jira_client(pr, config)
retry = False
while True:
try:
update_jira(client, config, pr)
break
except JIRAError:
# We got an error from Jira; if this was a re-try attempt, let the
# exception propagate (and crash the run).
if retry:
log.info("[PR] Jira retry failed; aborting")
raise
# The error may be due to expired/revoked auth. Ask get_jira_client to
# invalidate OAuth2 cache so the next call fetches a new token (no-op for PAT).
log.info("[PR] Jira request failed; refreshing the Jira client")
client = d_issue.get_jira_client(pr, config, invalidate_oauth2_cache=True)
# Retry the update
retry = True
def update_jira(client, config, pr):
# Check the status of the JIRA client
if not config["sync2jira"]["develop"] and not d_issue.check_jira_status(client):
log.warning("The JIRA server looks like its down. Shutting down...")
raise RuntimeError("Jira server status check failed; aborting...")
# Find our JIRA issue if one exists
if isinstance(pr, Issue):
# Instances of the `PR` type already have an initialized `jira_key`
# attribute; since what we have here is actually an `Issue`, we need to
# add it.
pr.jira_key = matcher(pr.content, pr.comments)
if not pr.jira_key:
create_pr_issue = pr.downstream.get("create_pr_issue", False)
if create_pr_issue:
if existing := d_issue.get_existing_jira_issue(client, pr, config):
log.info(f"Found existing JIRA issue {existing.key} for PR {pr.url}")
update_jira_issue(existing, pr, client)
else:
_create_jira_issue_from_pr(client, pr, config)
else:
log.info("No JIRA key found in PR, skipping.")
return
response: ResultList[JIRAIssue] = client.search_issues(f"Key = {pr.jira_key}")
if len(response) == 1:
# Syncing relevant information
log.info(f"Syncing PR {pr.url}")
update_jira_issue(response[0], pr, client)
log.info(f"Done syncing PR {pr.url}")
elif len(response) == 0:
log.info(f"No issue found for referenced key, {pr.jira_key}")
else:
log.warning(
f"Unexpectedly received {len(response)} matches for JIRA issue {pr.jira_key}"
)
def _create_jira_issue_from_pr(client, pr, config):
"""
Create a new JIRA issue from a PR when no JIRA key is referenced.
:param jira.client.JIRA client: JIRA client
:param sync2jira.intermediary.PR pr: PR object
:param Dict config: Config dict
:returns: Created JIRA issue
:rtype: jira.resources.Issue
"""
pr_content = pr.content or f"PR: {pr.url}"
if pr.downstream.get("issue_updates"):
if (
pr.source == "github"
and pr_content
and "github_markdown" in pr.downstream["issue_updates"]
):
pr_content = d_issue.convert_content(pr_content)
# Convert PR to Issue-like object for creation
# PR and Issue share similar structure, but we need to adapt it
issue_like = Issue(
source=pr.source,
title=pr._title, # Use original title without [upstream] prefix
url=pr.url,
upstream=pr.upstream,
comments=pr.comments,
config=config,
tags=[], # PRs don't have tags in the same way
fixVersion=[],
priority=pr.priority,
content=pr_content,
reporter=(
pr.reporter
if isinstance(pr.reporter, dict)
else {"fullname": str(pr.reporter)}
),
assignee=pr.assignee or [],
status=pr.status,
id_=pr.id,
storypoints=None,
upstream_id=pr.id,
issue_type=None,
downstream=pr.downstream, # Use PR's downstream config
)
# Use existing issue creation logic
return d_issue._create_jira_issue(client, issue_like, config)