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
3 changes: 2 additions & 1 deletion sync2jira/downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,8 @@ def _update_github_project_fields(
for name, values in github_project_fields.items():
if name not in dir(issue):
log.error(
f"Configuration error: github_project_field key, {name:r}, is not in issue object."
"Configuration error: github_project_field key, %r, is not in issue object.",
name,
)
continue

Expand Down
2 changes: 1 addition & 1 deletion sync2jira/downstream_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ def _create_jira_issue_from_pr(client, pr, config):
assignee=pr.assignee or [],
status=pr.status,
id_=pr.id,
storypoints=None,
storypoints=pr.storypoints,
upstream_id=pr.id,
issue_type=None,
downstream=pr.downstream, # Use PR's downstream config
Expand Down
5 changes: 4 additions & 1 deletion sync2jira/intermediary.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def __init__(
tags,
fixVersion,
priority,
storypoints,
content,
reporter,
assignee,
Expand All @@ -169,6 +170,7 @@ def __init__(
self.tags = tags
self.fixVersion = fixVersion
self.priority = priority
self.storypoints = storypoints
self.base_branch = base_branch

# JIRA treats utf-8 characters in ways we don't totally understand, so scrub content down to
Expand Down Expand Up @@ -245,7 +247,8 @@ def from_github(cls, upstream, pr, suffix, config, action=None):
comments=comments,
tags=pr.get("labels", []),
fixVersion=[pr.get("milestone")],
priority=None,
priority=pr.get("priority"),
storypoints=pr.get("storypoints"),
content=pr.get("body"),
reporter=pr["user"]["fullname"],
assignee=pr["assignee"],
Expand Down
54 changes: 37 additions & 17 deletions sync2jira/upstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@
}
"""

ghquery_pr = ghquery.replace(
"issue(number: $issuenumber)", "pullRequest(number: $issuenumber)"
)


def get_github_client(config):
"""
Helper function returning headers and github_client built from config.

:param dict config: Config
:returns: (headers, github_client)
:rtype: tuple
"""

token = config["sync2jira"].get("github_token")
headers = {"Authorization": "token " + token} if token else {}
github_client = Github(token, retry=5)
return headers, github_client


def passes_github_filters(item, config, upstream, item_type="issue"):
"""
Expand Down Expand Up @@ -194,9 +213,7 @@ def handle_github_message(body, config, is_pr=False):
)
return None

token = config["sync2jira"].get("github_token")
headers = {"Authorization": "token " + token} if token else {}
github_client = Github(token, retry=5)
headers, github_client = get_github_client(config)
reformat_github_issue(issue, upstream, github_client)
add_project_values(issue, upstream, headers, config)
return i.Issue.from_github(upstream, issue, config)
Expand All @@ -211,9 +228,7 @@ def github_issues(upstream, config):
:returns: a generator for GitHub Issue objects
:rtype: Generator[sync2jira.intermediary.Issue]
"""
token = config["sync2jira"].get("github_token")
headers = {"Authorization": "token " + token} if token else {}
github_client = Github(token, retry=5)
headers, github_client = get_github_client(config)
for issue in generate_github_items("issues", upstream, config):
if "pull_request" in issue or "/pull/" in issue.get("html_url", ""):
# We don't want to copy these around
Expand All @@ -230,18 +245,19 @@ def github_issues(upstream, config):
yield i.Issue.from_github(upstream, issue, config)


def add_project_values(issue, upstream, headers, config):
"""Add values to an issue from its corresponding card in a GitHub Project
def add_project_values(issue, upstream, headers, config, updates_key="issue_updates"):
"""Add values to an issue/PR from its corresponding card in a GitHub Project

:param dict issue: Issue
:param dict issue: Issue or PR dict
:param str upstream: Upstream repo name
:param dict headers: HTTP Request headers, including access token, if any
:param dict config: Config
:param str updates_key: Config key for the updates list
"""
upstream_config = config["sync2jira"]["map"]["github"][upstream]
issue_updates = upstream_config.get("issue_updates", [])
updates = upstream_config.get(updates_key, [])
github_project_fields = upstream_config.get("github_project_fields")
if not github_project_fields or "github_project_fields" not in issue_updates:
if not github_project_fields or "github_project_fields" not in updates:
log.debug(
"github_project_fields is None or empty, skipping project field updates"
)
Expand All @@ -252,31 +268,35 @@ def add_project_values(issue, upstream, headers, config):
issuenumber = issue["number"]
orgname, reponame = upstream.rsplit("/", 1)
variables = {"orgname": orgname, "reponame": reponame, "issuenumber": issuenumber}
query = ghquery_pr if updates_key == "pr_updates" else ghquery
response = requests.post(
graphqlurl, headers=headers, json={"query": ghquery, "variables": variables}
graphqlurl, headers=headers, json={"query": query, "variables": variables}
)
if response.status_code != 200:
log.info(
"HTTP error while fetching issue %s/%s#%s: %s",
"HTTP error while fetching %s %s/%s#%s: %s",
"PR" if updates_key == "pr_updates" else "issue",
orgname,
reponame,
issuenumber,
response.text,
)
return
data = response.json()
gh_issue = data.get("data", {}).get("repository", {}).get("issue")
if not gh_issue:
repo_data = data.get("data", {}).get("repository", {})
gh_item = repo_data.get("pullRequest" if updates_key == "pr_updates" else "issue")
if not gh_item:
log.info(
"GitHub error while fetching issue %s/%s#%s: %s",
"GitHub error while fetching %s %s/%s#%s: %s",
"PR" if updates_key == "pr_updates" else "issue",
orgname,
reponame,
issuenumber,
response.text,
)
return
project_node = _get_current_project_node(
upstream, project_number, issuenumber, gh_issue
upstream, project_number, issuenumber, gh_item
)
if not project_node:
return
Expand Down
9 changes: 5 additions & 4 deletions sync2jira/upstream_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import logging

from github import Github, UnknownObjectException
from github import UnknownObjectException

import sync2jira.intermediary as i
import sync2jira.upstream_issue as u_issue
Expand All @@ -44,9 +44,9 @@ def handle_github_message(body, config, suffix):
pr = body["pull_request"]
if not u_issue.passes_github_filters(pr, config, upstream, item_type="PR"):
return None
token = config["sync2jira"].get("github_token")
github_client = Github(token, retry=5)
headers, github_client = u_issue.get_github_client(config)
reformat_github_pr(pr, upstream, github_client)
u_issue.add_project_values(pr, upstream, headers, config, "pr_updates")
return i.PR.from_github(upstream, pr, suffix, config, body.get("action"))


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


Expand Down
169 changes: 169 additions & 0 deletions tests/test_upstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,175 @@ def test_add_project_values_storypoints(self, mock_requests_post):
)
mock_requests_post.reset_mock()

@mock.patch(PATH + "requests.post")
def test_add_project_values_pr_early_exit(self, mock_requests_post):
"""Test add_project_values early exit when using pr_updates."""
upstream_config = {
"pr_updates": ["comments", "title"],
"github_project_number": 1,
}
self.mock_config["sync2jira"]["map"]["github"]["org/repo"] = upstream_config

mock_issue = {"number": 1234, "storypoints": None, "priority": None}

scenarios = (
("github_project_fields is None", None, ["github_project_fields"]),
("github_project_fields is empty", {}, ["github_project_fields"]),
(
"github_project_fields not in pr_updates",
{"storypoints": {"gh_field": "Estimate"}},
[],
),
)
for description, gpf, extra_updates in scenarios:
with self.subTest(description=description):
upstream_config["github_project_fields"] = gpf
upstream_config["pr_updates"] = ["comments", "title"] + extra_updates
result = u.add_project_values(
issue=mock_issue,
upstream="org/repo",
headers={},
config=self.mock_config,
updates_key="pr_updates",
)
mock_requests_post.assert_not_called()
self.assertIsNone(result)
mock_requests_post.reset_mock()

@mock.patch(PATH + "requests.post")
def test_add_project_values_pr(self, mock_requests_post):
"""Test add_project_values with pr_updates uses pullRequest query and response key.

The storypoints/priority processing logic is shared with issues and
is thoroughly tested by test_add_project_values_storypoints. This
test focuses on the PR-specific behavior: reading from pr_updates,
sending the pullRequest GraphQL query, and parsing the pullRequest
response key.
"""
upstream_config = {
"pr_updates": ["github_project_fields"],
"github_project_number": 1,
}
self.mock_config["sync2jira"]["map"]["github"]["org/repo"] = upstream_config

mock_issue = {"number": 1234, "storypoints": None, "priority": None}

mock_requests_post.return_value.status_code = 200

scenarios = (
(
"Storypoints via Number field",
{
"priority": {"gh_field": "Priority"},
"storypoints": {"gh_field": "Estimate"},
},
[
{"fieldName": {"name": "Priority"}, "name": "High"},
{"fieldName": {"name": "Estimate"}, "number": 5},
],
5,
"High",
),
(
"Storypoints via Single Select",
{
"priority": {"gh_field": "Priority"},
"storypoints": {
"gh_field": "Size",
"options": {"Small": 1, "Medium": 3, "Large": 8},
},
},
[
{"fieldName": {"name": "Size"}, "name": "Medium"},
{"fieldName": {"name": "Priority"}, "name": "Critical"},
],
3,
"Critical",
),
(
"Priority only, no storypoints config",
{
Comment thread
webbnh marked this conversation as resolved.
"priority": {"gh_field": "Priority"},
},
[
{"fieldName": {"name": "Priority"}, "name": "Low"},
],
None,
"Low",
),
(
"Storypoints only, no priority config",
{
"storypoints": {"gh_field": "Estimate"},
},
[
{"fieldName": {"name": "Estimate"}, "number": 8},
],
8,
None,
),
)

for description, gpf, field_nodes, expected_sp, expected_prio in scenarios:
with self.subTest(description=description):
upstream_config["github_project_fields"] = gpf
mock_issue["storypoints"] = None
mock_issue["priority"] = None

mock_requests_post.return_value.json.return_value = {
"data": {
"repository": {
"pullRequest": {
"projectItems": {
"nodes": [
{
"project": {
"title": "Project 1",
"number": 1,
},
"fieldValues": {"nodes": field_nodes},
}
]
}
}
}
}
}

u.add_project_values(
issue=mock_issue,
upstream="org/repo",
headers={},
config=self.mock_config,
updates_key="pr_updates",
)

query_sent = mock_requests_post.call_args[1]["json"]["query"]
self.assertIn(
"pullRequest(number:",
query_sent,
"GraphQL query should use pullRequest, not issue",
)
self.assertNotIn(
"issue(number:",
query_sent,
"GraphQL query should not contain issue(number:) for PRs",
)

self.assertEqual(
mock_issue["priority"],
expected_prio,
f"{description}: expected priority={expected_prio!r}, "
f"got {mock_issue['priority']!r}",
)
self.assertEqual(
mock_issue.get("storypoints"),
expected_sp,
f"{description}: expected storypoints={expected_sp}, "
f"got {mock_issue.get('storypoints')}",
)
mock_requests_post.reset_mock()

def test_passes_github_filters(self):
"""
Test passes_github_filters for labels, milestone, and other fields.
Expand Down
Loading
Loading