Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Finish the mvp Jira integration #271

Merged
merged 6 commits into from
Oct 25, 2023
Merged
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
19 changes: 17 additions & 2 deletions docs/details.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Here are the details of what the bot does.

.. _pr_to_jira:

Making a Jira issue for a pull request
--------------------------------------
When a pull request is opened
-----------------------------

The bot gets notifications from GitHub when a pull request is created in the
organizations and/or repos where it is configured. It's currently configured
Expand Down Expand Up @@ -113,3 +113,18 @@ organization.
openedx.org/add-to-projects: "openedx:23"

The bot never removes pull requests from projects.


Making a Jira issue for a pull request
--------------------------------------

The bot used to automatically make Jira issues for pull requests, but no longer
does. Now a Jira issue will be created if a specific label is added to the
pull request.

The bot is configured to know about a small handful of Jira servers, each with
a short "nickname". If you add a label of ``jira:xyz`` to a pull request, the
bot will create a Jira issue in the Jira server with the "xyz" nickname.

Each Jira server can specify a mapping of repos to other Jira details such as
the Jira project for the issue, and the issue type to create.
64 changes: 60 additions & 4 deletions openedx_webhooks/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Get information about people, repos, orgs, pull requests, etc.
"""
import csv
import fnmatch
import logging
import re
from typing import Dict, Iterable, Optional
Expand Down Expand Up @@ -61,18 +62,34 @@ def _read_github_file(repo_fullname: str, file_path: str, not_there: Optional[st
`file_path`: the path to the file within the repo.
`not_there`: if provided, text to return if the file (or repo) doesn't exist.

Returns:
The text of the file, or `not_there` if provided.
"""
return _read_github_url(_github_file_url(repo_fullname, file_path), not_there)

def _read_github_url(url: str, not_there: Optional[str] = None) -> str:
"""
Read the content of a GitHub URL.

`not_there` is for handling missing files. All other errors trying to
access the file are raised as exceptions.

Arguments:
`url`: the complete GitHub URL to read.
`not_there`: if provided, text to return if the file (or repo) doesn't exist.

Returns:
The text of the file, or `not_there` if provided.
"""
github = get_github_session()
data_file_url = _github_file_url(repo_fullname, file_path)
logger.debug(f"Grabbing data file from: {data_file_url}")
resp = github.get(data_file_url)
logger.debug(f"Grabbing data file from: {url}")
resp = github.get(url)
if resp.status_code == 404 and not_there is not None:
return not_there
resp.raise_for_status()
return resp.text


@memoize_timed(minutes=15)
def get_people_file():
"""
Expand Down Expand Up @@ -125,14 +142,20 @@ def get_orgs_file():


@memoize_timed(minutes=30)
def get_jira_info():
def get_jira_info() -> dict[str, JiraServer]:
"""
Get the dict mapping Jira nicknames to JiraServer objects.
"""
jira_info = {}
for key, info in _read_yaml_data_file(settings.JIRA_INFO_FILE).items():
jira_info[key.lower()] = JiraServer(**info)
return jira_info


def get_jira_server_info(jira_nick: str) -> JiraServer:
"""
Given a Jira nickname, get the JiraServer info about it.
"""
jira_info = get_jira_info()
jira_server = jira_info[jira_nick.lower()]
return jira_server
Expand Down Expand Up @@ -300,3 +323,36 @@ def projects_for_pr(pull_request: PrDict) -> Iterable[GhProject]:
org, number = spec.strip().split(":")
gh_projects.append((org, int(number)))
return gh_projects


def jira_details_for_pr(jira_nick: str, pr: PrDict) -> tuple[str, str]:
"""
Determine what Jira project and issuetype should be used.

The jira mapping file looks like this::

# Mapping from repo to Jira.
defaults:
type: Task
repos:
- name: openedx/edx-platform
project: ARCHBOM
type: Task
- name: nedbat/*
project: ARCHBOM
- name: "*"
project: CATCHALL
type: OtherType

"""

jira_info = get_jira_server_info(jira_nick)
mapping = yaml.safe_load(_read_github_url(jira_info.mapping))
repo_name = pr["base"]["repo"]["full_name"]
details = mapping.get("defaults", {})
for repo_info in mapping.get("repos", []):
if fnmatch.fnmatch(repo_name, repo_info["name"]):
details.update(repo_info)
break

return details["project"], details["type"]
63 changes: 15 additions & 48 deletions openedx_webhooks/tasks/pr_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import dataclasses
import itertools
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple, cast
from typing import Dict, List, Optional, Set, cast

from openedx_webhooks.auth import get_github_session, get_jira_session
from openedx_webhooks.bot_comments import (
Expand Down Expand Up @@ -41,11 +41,11 @@
from openedx_webhooks.info import (
get_blended_project_id,
get_bot_comments,
get_people_file,
is_bot_pull_request,
is_draft_pull_request,
is_internal_pull_request,
is_private_repo_no_cla_pull_request,
jira_details_for_pr,
projects_for_pr,
pull_request_has_cla,
repo_refuses_contributions,
Expand All @@ -61,9 +61,7 @@
)
from openedx_webhooks.types import GhProject, JiraId, PrDict, PrId
from openedx_webhooks.utils import (
get_jira_custom_fields,
log_check_response,
retry_get,
sentry_extra_context,
text_summary,
)
Expand Down Expand Up @@ -233,7 +231,14 @@ def desired_support_state(pr: PrDict) -> PrDesiredInfo:
raise Exception(f"A crash label was applied by {user}")

desired.jira_title = pr["title"]
desired.jira_description = pr["body"] or ""
print(pr)
desired.jira_description = (
"(From {url} by {user_url})\n------\n\n{body}"
).format(
url=pr["html_url"],
body=(pr["body"] or ""),
user_url=pr["user"]["html_url"],
)

blended_id = get_blended_project_id(pr)
if blended_id is not None:
Expand Down Expand Up @@ -400,16 +405,14 @@ def _make_jira_issue(self, jira_nick) -> None:
"""
Make a Jira issue in a particular Jira server.
"""
user_name, institution = get_name_and_institution_for_pr(self.pr)
project, issuetype = jira_details_for_pr(jira_nick, self.pr)
issue_data = self.actions.create_jira_issue(
jira_nick=jira_nick,
pr_url=self.pr["html_url"],
project="TODOXXX", # TODO: get the real project
project=project,
issuetype=issuetype,
summary=self.desired.jira_title,
description=self.desired.jira_description,
labels=list(self.desired.jira_labels),
user_name=user_name,
institution=institution,
)

jira_id = JiraId(jira_nick, issue_data["key"])
Expand Down Expand Up @@ -513,33 +516,6 @@ def _add_bot_comments(self):
assert needed_comments == set(), f"Couldn't make comments: {needed_comments}"


def get_name_and_institution_for_pr(pr: PrDict) -> Tuple[str, Optional[str]]:
"""
Get the author name and institution for a pull request.

The returned name will always be a string. The institution might be None.

Returns:
name, institution
"""
user = pr["user"]["login"]
people = get_people_file()

user_name = None
if user in people:
user_name = people[user].get("name", "")
if not user_name:
resp = retry_get(get_github_session(), pr["user"]["url"])
if resp.ok:
user_name = resp.json().get("name", user)
else:
user_name = user

institution = people.get(user, {}).get("institution", None)

return user_name, institution


class DryRunFixingActions:
"""
Implementation of actions for dry runs.
Expand Down Expand Up @@ -588,40 +564,31 @@ def initial_state(self, *, current: Dict, desired: Dict) -> None:
def create_jira_issue(
self, *,
jira_nick: str,
pr_url: str,
project: str,
issuetype: str,
summary: Optional[str],
description: Optional[str],
labels: List[str],
user_name: Optional[str],
institution: Optional[str],
) -> Dict:
"""
Create a new Jira issue for a pull request.

Returns the JSON describing the issue.
"""

custom_fields = get_jira_custom_fields(jira_nick)
new_issue = {
"fields": {
"project": {
"key": project,
},
"issuetype": {
"name": "Pull Request Review",
"name": issuetype,
},
"summary": summary,
"description": description,
"labels": labels,
"customfield_10904": pr_url, # "URL" is ambiguous, use the internal name.
custom_fields["PR Number"]: self.prid.number,
custom_fields["Repo"]: self.prid.full_name,
custom_fields["Contributor Name"]: user_name,
}
}
if institution:
new_issue["fields"][custom_fields["Customer"]] = [institution]
sentry_extra_context({"new_issue": new_issue})

logger.info(f"Creating new JIRA issue for PR {self.prid}...")
Expand Down
6 changes: 3 additions & 3 deletions openedx_webhooks/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ class JiraServer:

# A description of the server, suitable for the bot to comment,
# "I created an issue in {{ description }}."
description: str = "a Jira server"
description: str

mapping: str | None = None
project: str | None = None
# The URL to get jira-mapping.yaml from.
mapping: str


@dataclasses.dataclass(frozen=True)
Expand Down
20 changes: 5 additions & 15 deletions openedx_webhooks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
return decorated


class RequestFailed(Exception):
pass

def log_check_response(response, raise_for_status=True):
"""
Logs HTTP request and response at debug level and checks if it succeeded.
Expand All @@ -77,10 +80,9 @@
if raise_for_status:
try:
response.raise_for_status()
except Exception:
except Exception as exc:

Check warning on line 83 in openedx_webhooks/utils.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/utils.py#L83

Added line #L83 was not covered by tests
req = response.request
logger.exception(f"HTTP request failed: {req.method} {req.url}. Response body: {response.content}")
raise
raise RequestFailed(f"HTTP request failed: {req.method} {req.url}. Response body: {response.content}") from exc

Check warning on line 85 in openedx_webhooks/utils.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/utils.py#L85

Added line #L85 was not covered by tests


def log_rate_limit():
Expand Down Expand Up @@ -304,18 +306,6 @@
scope.set_extra(key, value)


@memoize_timed(minutes=30)
def get_jira_custom_fields(jira_nick: str):
"""
Return a name-to-id mapping for the custom fields on JIRA.
"""
session = get_jira_session(jira_nick)
field_resp = session.get("/rest/api/2/field")
field_resp.raise_for_status()
fields = field_resp.json()
return {f["name"]: f["id"] for f in fields if f["custom"]}


def get_jira_issue(jira_nick: str, key: str, missing_ok: bool = False) -> Optional[JiraDict]:
"""
Get the dictionary for a Jira issue, from its key.
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def fake_jira(requests_mocker, fake_repo_data):
@pytest.fixture
def fake_jira2(requests_mocker, fake_repo_data):
"""A FakeJira for the second server configured in our jira-info.yaml."""
the_fake_jira = FakeJira("https://anothertest.atlassian.net")
the_fake_jira = FakeJira("https://anotherorg.atlassian.net")
the_fake_jira.install_mocks(requests_mocker)
return the_fake_jira

Expand Down
Loading
Loading