Skip to content

Commit c164aa0

Browse files
Merge pull request #477 from Bala-Sakabattula/PR-to-issue-fixes
PR-to-issue fixes for story points and priority
2 parents 39d0bd8 + 58515de commit c164aa0

7 files changed

Lines changed: 269 additions & 29 deletions

File tree

sync2jira/downstream_issue.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1295,7 +1295,8 @@ def _update_github_project_fields(
12951295
for name, values in github_project_fields.items():
12961296
if name not in dir(issue):
12971297
log.error(
1298-
f"Configuration error: github_project_field key, {name:r}, is not in issue object."
1298+
"Configuration error: github_project_field key, %r, is not in issue object.",
1299+
name,
12991300
)
13001301
continue
13011302

sync2jira/downstream_pr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ def _create_jira_issue_from_pr(client, pr, config):
357357
assignee=pr.assignee or [],
358358
status=pr.status,
359359
id_=pr.id,
360-
storypoints=None,
360+
storypoints=pr.storypoints,
361361
upstream_id=pr.id,
362362
issue_type=None,
363363
downstream=pr.downstream, # Use PR's downstream config

sync2jira/intermediary.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def __init__(
150150
tags,
151151
fixVersion,
152152
priority,
153+
storypoints,
153154
content,
154155
reporter,
155156
assignee,
@@ -169,6 +170,7 @@ def __init__(
169170
self.tags = tags
170171
self.fixVersion = fixVersion
171172
self.priority = priority
173+
self.storypoints = storypoints
172174
self.base_branch = base_branch
173175

174176
# JIRA treats utf-8 characters in ways we don't totally understand, so scrub content down to
@@ -245,7 +247,8 @@ def from_github(cls, upstream, pr, suffix, config, action=None):
245247
comments=comments,
246248
tags=pr.get("labels", []),
247249
fixVersion=[pr.get("milestone")],
248-
priority=None,
250+
priority=pr.get("priority"),
251+
storypoints=pr.get("storypoints"),
249252
content=pr.get("body"),
250253
reporter=pr["user"]["fullname"],
251254
assignee=pr["assignee"],

sync2jira/upstream_issue.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,25 @@
109109
}
110110
"""
111111

112+
ghquery_pr = ghquery.replace(
113+
"issue(number: $issuenumber)", "pullRequest(number: $issuenumber)"
114+
)
115+
116+
117+
def get_github_client(config):
118+
"""
119+
Helper function returning headers and github_client built from config.
120+
121+
:param dict config: Config
122+
:returns: (headers, github_client)
123+
:rtype: tuple
124+
"""
125+
126+
token = config["sync2jira"].get("github_token")
127+
headers = {"Authorization": "token " + token} if token else {}
128+
github_client = Github(token, retry=5)
129+
return headers, github_client
130+
112131

113132
def passes_github_filters(item, config, upstream, item_type="issue"):
114133
"""
@@ -194,9 +213,7 @@ def handle_github_message(body, config, is_pr=False):
194213
)
195214
return None
196215

197-
token = config["sync2jira"].get("github_token")
198-
headers = {"Authorization": "token " + token} if token else {}
199-
github_client = Github(token, retry=5)
216+
headers, github_client = get_github_client(config)
200217
reformat_github_issue(issue, upstream, github_client)
201218
add_project_values(issue, upstream, headers, config)
202219
return i.Issue.from_github(upstream, issue, config)
@@ -211,9 +228,7 @@ def github_issues(upstream, config):
211228
:returns: a generator for GitHub Issue objects
212229
:rtype: Generator[sync2jira.intermediary.Issue]
213230
"""
214-
token = config["sync2jira"].get("github_token")
215-
headers = {"Authorization": "token " + token} if token else {}
216-
github_client = Github(token, retry=5)
231+
headers, github_client = get_github_client(config)
217232
for issue in generate_github_items("issues", upstream, config):
218233
if "pull_request" in issue or "/pull/" in issue.get("html_url", ""):
219234
# We don't want to copy these around
@@ -230,18 +245,19 @@ def github_issues(upstream, config):
230245
yield i.Issue.from_github(upstream, issue, config)
231246

232247

233-
def add_project_values(issue, upstream, headers, config):
234-
"""Add values to an issue from its corresponding card in a GitHub Project
248+
def add_project_values(issue, upstream, headers, config, updates_key="issue_updates"):
249+
"""Add values to an issue/PR from its corresponding card in a GitHub Project
235250
236-
:param dict issue: Issue
251+
:param dict issue: Issue or PR dict
237252
:param str upstream: Upstream repo name
238253
:param dict headers: HTTP Request headers, including access token, if any
239254
:param dict config: Config
255+
:param str updates_key: Config key for the updates list
240256
"""
241257
upstream_config = config["sync2jira"]["map"]["github"][upstream]
242-
issue_updates = upstream_config.get("issue_updates", [])
258+
updates = upstream_config.get(updates_key, [])
243259
github_project_fields = upstream_config.get("github_project_fields")
244-
if not github_project_fields or "github_project_fields" not in issue_updates:
260+
if not github_project_fields or "github_project_fields" not in updates:
245261
log.debug(
246262
"github_project_fields is None or empty, skipping project field updates"
247263
)
@@ -252,31 +268,35 @@ def add_project_values(issue, upstream, headers, config):
252268
issuenumber = issue["number"]
253269
orgname, reponame = upstream.rsplit("/", 1)
254270
variables = {"orgname": orgname, "reponame": reponame, "issuenumber": issuenumber}
271+
query = ghquery_pr if updates_key == "pr_updates" else ghquery
255272
response = requests.post(
256-
graphqlurl, headers=headers, json={"query": ghquery, "variables": variables}
273+
graphqlurl, headers=headers, json={"query": query, "variables": variables}
257274
)
258275
if response.status_code != 200:
259276
log.info(
260-
"HTTP error while fetching issue %s/%s#%s: %s",
277+
"HTTP error while fetching %s %s/%s#%s: %s",
278+
"PR" if updates_key == "pr_updates" else "issue",
261279
orgname,
262280
reponame,
263281
issuenumber,
264282
response.text,
265283
)
266284
return
267285
data = response.json()
268-
gh_issue = data.get("data", {}).get("repository", {}).get("issue")
269-
if not gh_issue:
286+
repo_data = data.get("data", {}).get("repository", {})
287+
gh_item = repo_data.get("pullRequest" if updates_key == "pr_updates" else "issue")
288+
if not gh_item:
270289
log.info(
271-
"GitHub error while fetching issue %s/%s#%s: %s",
290+
"GitHub error while fetching %s %s/%s#%s: %s",
291+
"PR" if updates_key == "pr_updates" else "issue",
272292
orgname,
273293
reponame,
274294
issuenumber,
275295
response.text,
276296
)
277297
return
278298
project_node = _get_current_project_node(
279-
upstream, project_number, issuenumber, gh_issue
299+
upstream, project_number, issuenumber, gh_item
280300
)
281301
if not project_node:
282302
return

sync2jira/upstream_pr.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import logging
2121

22-
from github import Github, UnknownObjectException
22+
from github import UnknownObjectException
2323

2424
import sync2jira.intermediary as i
2525
import sync2jira.upstream_issue as u_issue
@@ -44,9 +44,9 @@ def handle_github_message(body, config, suffix):
4444
pr = body["pull_request"]
4545
if not u_issue.passes_github_filters(pr, config, upstream, item_type="PR"):
4646
return None
47-
token = config["sync2jira"].get("github_token")
48-
github_client = Github(token, retry=5)
47+
headers, github_client = u_issue.get_github_client(config)
4948
reformat_github_pr(pr, upstream, github_client)
49+
u_issue.add_project_values(pr, upstream, headers, config, "pr_updates")
5050
return i.PR.from_github(upstream, pr, suffix, config, body.get("action"))
5151

5252

@@ -59,9 +59,10 @@ def github_prs(upstream, config):
5959
:returns: a generator for GitHub PR objects
6060
:rtype: Generator[sync2jira.intermediary.PR]
6161
"""
62-
github_client = Github(config["sync2jira"]["github_token"])
62+
headers, github_client = u_issue.get_github_client(config)
6363
for pr in u_issue.generate_github_items("pulls", upstream, config):
6464
reformat_github_pr(pr, upstream, github_client)
65+
u_issue.add_project_values(pr, upstream, headers, config, "pr_updates")
6566
yield i.PR.from_github(upstream, pr, "open", config)
6667

6768

tests/test_upstream_issue.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,175 @@ def test_add_project_values_storypoints(self, mock_requests_post):
10091009
)
10101010
mock_requests_post.reset_mock()
10111011

1012+
@mock.patch(PATH + "requests.post")
1013+
def test_add_project_values_pr_early_exit(self, mock_requests_post):
1014+
"""Test add_project_values early exit when using pr_updates."""
1015+
upstream_config = {
1016+
"pr_updates": ["comments", "title"],
1017+
"github_project_number": 1,
1018+
}
1019+
self.mock_config["sync2jira"]["map"]["github"]["org/repo"] = upstream_config
1020+
1021+
mock_issue = {"number": 1234, "storypoints": None, "priority": None}
1022+
1023+
scenarios = (
1024+
("github_project_fields is None", None, ["github_project_fields"]),
1025+
("github_project_fields is empty", {}, ["github_project_fields"]),
1026+
(
1027+
"github_project_fields not in pr_updates",
1028+
{"storypoints": {"gh_field": "Estimate"}},
1029+
[],
1030+
),
1031+
)
1032+
for description, gpf, extra_updates in scenarios:
1033+
with self.subTest(description=description):
1034+
upstream_config["github_project_fields"] = gpf
1035+
upstream_config["pr_updates"] = ["comments", "title"] + extra_updates
1036+
result = u.add_project_values(
1037+
issue=mock_issue,
1038+
upstream="org/repo",
1039+
headers={},
1040+
config=self.mock_config,
1041+
updates_key="pr_updates",
1042+
)
1043+
mock_requests_post.assert_not_called()
1044+
self.assertIsNone(result)
1045+
mock_requests_post.reset_mock()
1046+
1047+
@mock.patch(PATH + "requests.post")
1048+
def test_add_project_values_pr(self, mock_requests_post):
1049+
"""Test add_project_values with pr_updates uses pullRequest query and response key.
1050+
1051+
The storypoints/priority processing logic is shared with issues and
1052+
is thoroughly tested by test_add_project_values_storypoints. This
1053+
test focuses on the PR-specific behavior: reading from pr_updates,
1054+
sending the pullRequest GraphQL query, and parsing the pullRequest
1055+
response key.
1056+
"""
1057+
upstream_config = {
1058+
"pr_updates": ["github_project_fields"],
1059+
"github_project_number": 1,
1060+
}
1061+
self.mock_config["sync2jira"]["map"]["github"]["org/repo"] = upstream_config
1062+
1063+
mock_issue = {"number": 1234, "storypoints": None, "priority": None}
1064+
1065+
mock_requests_post.return_value.status_code = 200
1066+
1067+
scenarios = (
1068+
(
1069+
"Storypoints via Number field",
1070+
{
1071+
"priority": {"gh_field": "Priority"},
1072+
"storypoints": {"gh_field": "Estimate"},
1073+
},
1074+
[
1075+
{"fieldName": {"name": "Priority"}, "name": "High"},
1076+
{"fieldName": {"name": "Estimate"}, "number": 5},
1077+
],
1078+
5,
1079+
"High",
1080+
),
1081+
(
1082+
"Storypoints via Single Select",
1083+
{
1084+
"priority": {"gh_field": "Priority"},
1085+
"storypoints": {
1086+
"gh_field": "Size",
1087+
"options": {"Small": 1, "Medium": 3, "Large": 8},
1088+
},
1089+
},
1090+
[
1091+
{"fieldName": {"name": "Size"}, "name": "Medium"},
1092+
{"fieldName": {"name": "Priority"}, "name": "Critical"},
1093+
],
1094+
3,
1095+
"Critical",
1096+
),
1097+
(
1098+
"Priority only, no storypoints config",
1099+
{
1100+
"priority": {"gh_field": "Priority"},
1101+
},
1102+
[
1103+
{"fieldName": {"name": "Priority"}, "name": "Low"},
1104+
],
1105+
None,
1106+
"Low",
1107+
),
1108+
(
1109+
"Storypoints only, no priority config",
1110+
{
1111+
"storypoints": {"gh_field": "Estimate"},
1112+
},
1113+
[
1114+
{"fieldName": {"name": "Estimate"}, "number": 8},
1115+
],
1116+
8,
1117+
None,
1118+
),
1119+
)
1120+
1121+
for description, gpf, field_nodes, expected_sp, expected_prio in scenarios:
1122+
with self.subTest(description=description):
1123+
upstream_config["github_project_fields"] = gpf
1124+
mock_issue["storypoints"] = None
1125+
mock_issue["priority"] = None
1126+
1127+
mock_requests_post.return_value.json.return_value = {
1128+
"data": {
1129+
"repository": {
1130+
"pullRequest": {
1131+
"projectItems": {
1132+
"nodes": [
1133+
{
1134+
"project": {
1135+
"title": "Project 1",
1136+
"number": 1,
1137+
},
1138+
"fieldValues": {"nodes": field_nodes},
1139+
}
1140+
]
1141+
}
1142+
}
1143+
}
1144+
}
1145+
}
1146+
1147+
u.add_project_values(
1148+
issue=mock_issue,
1149+
upstream="org/repo",
1150+
headers={},
1151+
config=self.mock_config,
1152+
updates_key="pr_updates",
1153+
)
1154+
1155+
query_sent = mock_requests_post.call_args[1]["json"]["query"]
1156+
self.assertIn(
1157+
"pullRequest(number:",
1158+
query_sent,
1159+
"GraphQL query should use pullRequest, not issue",
1160+
)
1161+
self.assertNotIn(
1162+
"issue(number:",
1163+
query_sent,
1164+
"GraphQL query should not contain issue(number:) for PRs",
1165+
)
1166+
1167+
self.assertEqual(
1168+
mock_issue["priority"],
1169+
expected_prio,
1170+
f"{description}: expected priority={expected_prio!r}, "
1171+
f"got {mock_issue['priority']!r}",
1172+
)
1173+
self.assertEqual(
1174+
mock_issue.get("storypoints"),
1175+
expected_sp,
1176+
f"{description}: expected storypoints={expected_sp}, "
1177+
f"got {mock_issue.get('storypoints')}",
1178+
)
1179+
mock_requests_post.reset_mock()
1180+
10121181
def test_passes_github_filters(self):
10131182
"""
10141183
Test passes_github_filters for labels, milestone, and other fields.

0 commit comments

Comments
 (0)