mongo/buildscripts/create_todo_tickets.py
Daniel Moody bf53c448c0 SERVER-117777 fix create todo script to fallback on invalid team (#46963)
GitOrigin-RevId: 57d6ffd4759fd60a7df4bd94a011483059acecab
2026-01-27 18:56:02 +00:00

224 lines
7.8 KiB
Python

"""
Walk all files in a directory, checking whether any TODOs are linked to a
resolved JIRA ticket, and labeling those JIRA tickets.
"""
#!/usr/bin/env python3
import argparse
import os
import re
import sys
import structlog
from jira import JIRAError
from structlog.stdlib import LoggerFactory
# Get relative imports to work when the package is not installed on the PYTHONPATH.
if __name__ == "__main__" and __package__ is None:
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from buildscripts.client.jiraclient import JiraAuth, JiraClient
structlog.configure(logger_factory=LoggerFactory())
LOG = structlog.getLogger(__name__)
JIRA_SERVER = "https://jira.mongodb.org"
def _get_jira_field_errors(err: JIRAError) -> dict:
"""
Best-effort extraction of field-level errors from a JIRAError.
JIRA commonly returns:
{"errorMessages": [], "errors": {"customfield_12751": "Option value 'X' is not valid"}}
"""
response = getattr(err, "response", None)
if response is None:
return {}
json_fn = getattr(response, "json", None)
if json_fn is None:
return {}
try:
payload = json_fn()
except Exception:
return {}
if not isinstance(payload, dict):
return {}
errors = payload.get("errors")
return errors if isinstance(errors, dict) else {}
def find_todos(search_file, jira, file_name):
"""Iterate through a file, finding TODOs with resolved tickets and creating new tickets."""
for i, line in enumerate(search_file):
if "todo" in line.lower():
issue_key = get_issue_key_from_line(line)
if issue_key:
LOG.info("\n")
LOG.info("=== Found Issue Key ===")
LOG.info(line.lstrip().rstrip())
LOG.info(f"{JIRA_SERVER}/browse/{issue_key}")
LOG.info(f"Found in {file_name} on line {i+1}")
# It is possible the referenced ticket in the code was deleted, so we need to
# handle the possibility that searching for it will return nothing.
try:
issue = jira.issue(issue_key)
except JIRAError:
LOG.warn(f"{issue_key} not found in Jira. Skipping.")
continue
status = str(issue.fields.status)
if status not in ["Resolved", "Closed"]:
LOG.info(f"{issue_key} is not resolved. Skipping.")
continue
if todo_ticket_exists(jira, issue):
LOG.info(f"Autogenerated ticket linked to {issue_key} already exists.")
continue
create_todo_ticket(jira, issue)
def todo_ticket_exists(jira, resolved_issue):
"""Check if we have already created a ticket for this resolved issue"""
jql = (
"labels = autogen-todo AND resolution is empty AND issueFunction in"
f" linkedIssuesOf('key={resolved_issue.key}')"
)
results = jira.search_issues(jql, maxResults=1000)
if not results or results.total == 0:
return False
return True
def create_todo_ticket(jira, resolved_issue):
"""Given a resolved ticket, create a new ticket for that work and link to the resolved ticket."""
key = resolved_issue.key
assignee = get_assignable_user(resolved_issue)
assigned_team = resolved_issue.fields.customfield_12751
# Derive Team from Resolved ticket
team = assigned_team[0].value if assigned_team else "Server Triage"
issue_dict = {
"project": {"key": "SERVER"},
"issuetype": {"name": "Task"},
"summary": "Complete TODO listed in " + key,
"assignee": {"name": assignee},
"description": construct_description(key),
"labels": ["autogen-todo"],
"customfield_12751": [{"value": team}],
}
if "REP" in key:
issue_dict["project"] = {"key": "REP"}
# It's possible for us to try and create a ticket with an illegal assignee (most commonly
# a former employee) so for now we default to hard assigning these to Joe to reassign.
# This situation should be very infrequent.
fallback_assignee = "joseph.kanaan@mongodb.com"
fallback_team = "Server Triage"
new_issue = None
for attempt in range(3):
try:
new_issue = jira.create_issue(fields=issue_dict)
break
except JIRAError as err:
field_errors = _get_jira_field_errors(err)
updated = False
# If the resolved issue has a team value that isn't valid for the target project/type,
# fall back to "Server Triage" and retry.
if (
"customfield_12751" in field_errors
or "customfield_12751" in str(err)
or "Option value" in str(err)
):
current_team = issue_dict.get("customfield_12751", [{}])[0].get("value")
if current_team != fallback_team:
LOG.warning(
"Invalid Jira team option; falling back to Server Triage",
team=current_team,
key=key,
)
issue_dict["customfield_12751"] = [{"value": fallback_team}]
updated = True
# Preserve existing behavior: retry once with a known-good assignee.
current_assignee = issue_dict.get("assignee", {}).get("name")
if current_assignee != fallback_assignee:
issue_dict["assignee"]["name"] = fallback_assignee
updated = True
if not updated or attempt == 2:
raise
if new_issue is None:
raise RuntimeError("Failed to create todo ticket after retries")
jira.create_issue_link(
type="Related", inwardIssue=resolved_issue.key, outwardIssue=new_issue.key
)
def construct_description(key):
repo = "10gen/mongosync" if "REP" in key else "10gen/mongo"
return (
"There is a TODO in the codebase referencing a resolved ticket which is"
" assigned to you.\n\nPlease follow this link to see the lines of code"
" referencing this resolved"
f" ticket:\nhttps://github.com/{repo}/search?q={key}&type=Code\n\nThe next"
" steps for this ticket are to either remove the outdated TODO or follow the"
" steps in the TODO if it is correct. If the latter, please update the summary"
" and description of this ticket to represent the work you're actually doing."
)
def get_assignable_user(ticket):
"""If the original ticket is assigned to an existing employee, return the assignee"""
assignee = None
if ticket.fields.assignee:
assignee = ticket.fields.assignee.name
return assignee
def get_issue_key_from_line(line):
"""Given a string of text, find and return any issue keys from relevent projects."""
match = re.search(
"(BUILD|SERVER|WT|SPM|TOOLS|TIG|PERF|BF|REP|BACKPORT|WRITING|STAR|SLS)-[0-9]+",
line,
re.IGNORECASE,
)
if match:
return match.group(0)
def main():
"""Execute main function."""
argparser = argparse.ArgumentParser(description="")
argparser.add_argument("--env", "-e", help="Jira environment {stg, prod}", required=False)
argparser.add_argument("--path", "-p", help="File path to walk", required=True)
args = vars(argparser.parse_args())
jira = JiraClient(JIRA_SERVER, JiraAuth(), dry_run=False)
for root, _, files in os.walk(args["path"]):
for file_name in files:
# ignore .git/
if ".git" in root:
continue
try:
with open(os.path.join(root, file_name), "r") as search_file:
find_todos(search_file, jira._jira, file_name)
search_file.close()
except UnicodeDecodeError:
continue
if __name__ == "__main__":
main()