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

Labels trigger Jira issue creation #265

Merged
merged 12 commits into from
Sep 28, 2023
5 changes: 5 additions & 0 deletions changelog.d/20230927_140851_nedbat_label_for_jira.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.. A new scriv changelog fragment.

- Adding a label like ``jira:xyz`` to a pull request will look in a private
registry of Jira servers for one nicknamed ``xyz``, and then create a Jira
issue there to correspond to the pull request.
4 changes: 2 additions & 2 deletions openedx_webhooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def expand_config(name=None):

def create_app(config=None):
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app)
app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore[method-assign]
config = config or os.environ.get("OPENEDX_WEBHOOKS_CONFIG") or "default"
# Instantiate the config object because we rely on the __init__
# function to translate config between heroku and what sqlalchemy wants
Expand Down Expand Up @@ -70,7 +70,7 @@ def create_celery_app(app=None, config="worker"):
app = app or create_app(config=config)
celery.main = app.import_name
celery.conf.update(app.config)
class ContextTask(celery.Task):
class ContextTask(celery.Task): # type: ignore[name-defined]
abstract = True
def __call__(self, *args, **kwargs):
if "wsgi_environ" in kwargs:
Expand Down
12 changes: 9 additions & 3 deletions openedx_webhooks/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@ def request(self, method, url, data=None, headers=None, **kwargs):
)


def get_jira_session():
def get_jira_session(jira_nick):
"""
Get the Jira session to use, in an easily test-patchable way.

`jira_nick` is a nickname for one of our configured Jira servers.
"""
session = BaseUrlSession(base_url=settings.JIRA_SERVER)
session.auth = (settings.JIRA_USER_EMAIL, settings.JIRA_USER_TOKEN)
# Avoid a circular import.
from openedx_webhooks.info import get_jira_server_info

jira_server = get_jira_server_info(jira_nick)
session = BaseUrlSession(base_url=jira_server.server)
session.auth = (jira_server.email, jira_server.token)
session.trust_env = False # prevent reading the local .netrc
return session

Expand Down
14 changes: 13 additions & 1 deletion openedx_webhooks/bot_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
from flask import render_template

from openedx_webhooks.info import (
get_jira_server_info,
is_draft_pull_request,
pull_request_has_cla,
)
from openedx_webhooks.types import PrDict
from openedx_webhooks.types import JiraId, PrDict


class BotComment(Enum):
Expand Down Expand Up @@ -152,6 +153,17 @@ def github_end_survey_comment(pull_request: PrDict) -> str:
)


def jira_issue_comment(pull_request: PrDict, jira_id: JiraId) -> str: # pylint: disable=unused-argument
"""Render a comment about making a new Jira issue."""
jira_server = get_jira_server_info(jira_id.nick)
return render_template(
"jira_issue_comment.md.j2",
server_url=jira_server.server,
server_description=jira_server.description,
key=jira_id.key,
)


def no_contributions_thanks(pull_request: PrDict) -> str: # pylint: disable=unused-argument
"""
Create a "no contributions" comment.
Expand Down
11 changes: 6 additions & 5 deletions openedx_webhooks/github_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import logging
from typing import cast

from flask import current_app as app
from flask import Blueprint, jsonify, render_template, request
Expand Down Expand Up @@ -37,7 +38,7 @@
"""
signature = request.headers.get("X-Hub-Signature")
secret = app.config.get('GITHUB_WEBHOOKS_SECRET')
if not is_valid_payload(secret, signature, request.data):
if not is_valid_payload(secret, signature, request.data): # type: ignore[arg-type]
msg = "Rejecting because signature doesn't match!"
logging.info(msg)
return msg, 403
Expand Down Expand Up @@ -90,6 +91,7 @@
"converted_to_draft",
"reopened",
"enqueued",
"labeled",
}

def handle_pull_request_event(event):
Expand Down Expand Up @@ -157,7 +159,7 @@
:func:`~openedx_webhooks.tasks.github.pull_request_changed`
must be idempotent. It could run many times over the same pull request.
"""
repo = request.form.get("repo")
repo = cast(str, request.form.get("repo"))

Check warning on line 162 in openedx_webhooks/github_views.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/github_views.py#L162

Added line #L162 was not covered by tests
inline = bool(request.form.get("inline", False))

rescan_kwargs = dict( # pylint: disable=use-dict-literal
Expand All @@ -174,7 +176,7 @@
org = repo[4:]
return queue_task(rescan_organization_task, org, **rescan_kwargs)
elif inline:
return jsonify(rescan_repository(repo, **rescan_kwargs))
return jsonify(rescan_repository(repo, **rescan_kwargs)) # type: ignore[arg-type]

Check warning on line 179 in openedx_webhooks/github_views.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/github_views.py#L179

Added line #L179 was not covered by tests
else:
return queue_task(rescan_repository_task, repo, **rescan_kwargs)

Expand Down Expand Up @@ -204,8 +206,7 @@
resp = jsonify({"error": "Pull request number required"})
resp.status_code = 400
return resp
num = int(num)
pr_resp = get_github_session().get("/repos/{repo}/pulls/{num}".format(repo=repo, num=num))
pr_resp = get_github_session().get(f"/repos/{repo}/pulls/{num}")

Check warning on line 209 in openedx_webhooks/github_views.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/github_views.py#L209

Added line #L209 was not covered by tests
if not pr_resp.ok:
resp = jsonify({"error": pr_resp.text})
resp.status_code = 400
Expand Down
45 changes: 17 additions & 28 deletions openedx_webhooks/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
import csv
import logging
import re
from typing import Dict, Iterable, Optional, Tuple, Union
from typing import Dict, Iterable, Optional

import yaml
from glom import glom

from openedx_webhooks import settings
from openedx_webhooks.auth import get_github_session
from openedx_webhooks.types import GhProject, PrDict, PrCommentDict, PrId
from openedx_webhooks.types import GhProject, JiraServer, PrDict, PrCommentDict, PrId
from openedx_webhooks.utils import (
memoize,
memoize_timed,
Expand Down Expand Up @@ -123,6 +122,21 @@ def get_orgs_file():
orgs[org_data["name"]] = org_data
return orgs


@memoize_timed(minutes=30)
def get_jira_info():
jira_info = {}
for key, info in _read_yaml_data_file("jira-info.yaml").items():
jira_info[key.lower()] = JiraServer(**info)
return jira_info


def get_jira_server_info(jira_nick: str) -> JiraServer:
jira_info = get_jira_info()
jira_server = jira_info[jira_nick.lower()]
return jira_server


def is_internal_pull_request(pull_request: PrDict) -> bool:
"""
Is this pull request's author internal to the PR's GitHub org?
Expand Down Expand Up @@ -252,31 +266,6 @@ def get_bot_comments(prid: PrId) -> Iterable[PrCommentDict]:
yield comment


def get_jira_issue_key(pr: Union[PrId, PrDict]) -> Tuple[bool, Optional[str]]:
"""
Find mention of a Jira issue number in bot-authored comments.

Returns:
on_our_jira (bool): is the Jira issue on the JIRA_SERVER?
issue_key (str): the id of the Jira issue. Can be None if no Jira issue
is on the pull request.
"""
if isinstance(pr, PrDict):
prid = PrId.from_pr_dict(pr)
else:
prid = pr
for comment in get_bot_comments(prid):
# search for the first occurrence of a JIRA ticket key in the comment body
match = re.search(r"(https://.*?)/browse/([A-Z]{2,}-\d+)\b", comment["body"])
if match:
on_our_jira = (match[1] == settings.JIRA_SERVER)
jira_key = match[2]
return on_our_jira, jira_key
# If there is no jira id yet, return on_our_jira==True so that we will work
# on Jira to make new ids.
return True, None


def get_catalog_info(repo_fullname: str) -> Dict:
"""Get the parsed catalog-info.yaml data from a repo, or {} if missing."""
yml = _read_github_file(repo_fullname, "catalog-info.yaml", not_there="{}")
Expand Down
6 changes: 0 additions & 6 deletions openedx_webhooks/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,3 @@
"blended",
"open-source-contribution",
}

# These are categorization labels the bot assigns.

JIRA_CATEGORY_LABELS = {
"blended",
}
21 changes: 2 additions & 19 deletions openedx_webhooks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@
from openedx_webhooks.types import GhProject


# The Jira server to use. Missing or "" will become None,
# meaning don't use Jira at all.
JIRA_SERVER = os.environ.get("JIRA_SERVER", None) or None
JIRA_USER_EMAIL = os.environ.get("JIRA_USER_EMAIL", None)
JIRA_USER_TOKEN = os.environ.get("JIRA_USER_TOKEN", None)

GITHUB_PERSONAL_TOKEN = os.environ.get("GITHUB_PERSONAL_TOKEN", None)


def read_project_setting(setting_name: str) -> Optional[GhProject]:
"""Read a project spec from a setting.

Expand All @@ -31,19 +22,11 @@ def read_project_setting(setting_name: str) -> Optional[GhProject]:
return None


GITHUB_PERSONAL_TOKEN = os.environ.get("GITHUB_PERSONAL_TOKEN", None)

# The project all OSPRs should be added to.
# This should be in the form of org:num, like "openedx:19"
GITHUB_OSPR_PROJECT = read_project_setting("GITHUB_OSPR_PROJECT")

# The project all Blended pull requests should be added to.
GITHUB_BLENDED_PROJECT = read_project_setting("GITHUB_BLENDED_PROJECT")


# Made-up values to use while testing.
class TestSettings:
GITHUB_BLENDED_PROJECT = ("blendorg", 42)
GITHUB_OSPR_PROJECT = ("testorg", 17)
GITHUB_PERSONAL_TOKEN = "github_pat_FooBarBaz"
JIRA_SERVER = "https://test.atlassian.net"
JIRA_USER_EMAIL = "[email protected]"
JIRA_USER_TOKEN = "xyzzy-123-plugh"
48 changes: 23 additions & 25 deletions openedx_webhooks/tasks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import traceback

from typing import Dict, Optional, Tuple
from typing import Dict, Set

from urlobject import URLObject

Expand All @@ -16,9 +16,10 @@
current_support_state,
desired_support_state,
DryRunFixingActions,
FixResult,
PrTrackingFixer,
)
from openedx_webhooks.types import PrDict
from openedx_webhooks.types import JiraId, PrDict
from openedx_webhooks.utils import (
log_rate_limit,
paginated_get,
Expand All @@ -38,7 +39,7 @@
raise


def pull_request_changed(pr: PrDict, actions=None) -> Tuple[Optional[str], bool]:
def pull_request_changed(pr: PrDict, actions=None) -> FixResult:
"""
Process a pull request.

Expand All @@ -52,10 +53,7 @@
As a result, it should not comment on the pull request without checking to
see if it has *already* commented on the pull request.

Returns a 2-tuple. The first element in the tuple is the key of the JIRA
issue associated with the pull request, if any, as a string. The second
element in the tuple is a boolean indicating if this function did any
work, such as making a JIRA issue or commenting on the pull request.
Returns an object with details of the associated Jira issues.
"""

user = pr["user"]["login"]
Expand All @@ -65,13 +63,10 @@
logger.info(f"Processing PR {repo}#{num} by @{user}...")

desired = desired_support_state(pr)
if desired is not None:
current = current_support_state(pr)
fixer = PrTrackingFixer(pr, current, desired, actions=actions)
fixer.fix()
return fixer.result()
else:
return None, False
current = current_support_state(pr)
fixer = PrTrackingFixer(pr, current, desired, actions=actions)
fixer.fix()
return fixer.result()


class PaginateCallback:
Expand Down Expand Up @@ -137,7 +132,8 @@
state = "all" if allpr else "open"
url = f"/repos/{repo}/pulls?state={state}"

changed: Dict[int, Optional[str]] = {}
changed: Dict[int, Set[JiraId]] = {}
errors: Dict[int, str] = {}
dry_run_actions = {}

# Pull requests before this will not be rescanned. Contractor messages
Expand Down Expand Up @@ -167,15 +163,15 @@
resp.raise_for_status()
pull_request = resp.json()

issue_key, anything_happened = pull_request_changed(pull_request, actions=actions)
result = pull_request_changed(pull_request, actions=actions)
except Exception: # pylint: disable=broad-except
changed[pull_request["number"]] = traceback.format_exc()
errors[pull_request["number"]] = traceback.format_exc()
else:
if anything_happened:
changed[pull_request["number"]] = issue_key
if dry_run:
assert actions is not None
dry_run_actions[pull_request["number"]] = actions.action_calls
if result.changed_jira_issues:
changed[pull_request["number"]] = result.changed_jira_issues

Check warning on line 171 in openedx_webhooks/tasks/github.py

View check run for this annotation

Codecov / codecov/patch

openedx_webhooks/tasks/github.py#L171

Added line #L171 was not covered by tests
if dry_run:
assert actions is not None
dry_run_actions[pull_request["number"]] = actions.action_calls

if not dry_run:
logger.info(
Expand All @@ -184,9 +180,11 @@
),
)

info: Dict = {"repo": repo}
if changed:
info["changed"] = changed
info: Dict = {
"repo": repo,
"changed": changed,
"errors": errors,
}
if dry_run_actions:
info["dry_run_actions"] = dry_run_actions
return info
Expand Down
15 changes: 0 additions & 15 deletions openedx_webhooks/tasks/github_work.py

This file was deleted.

Loading
Loading