import json import os import re from urllib.parse import quote import jira import requests from github import Github from jira import JIRA COMMIT_PATTERN = re.compile(r'^(\w*):\s*(.*?)?:\s*(.*?)\s*(\(#(\d+)\))?$') RELEASE_VERSION = os.getenv("RELEASE_VERSION").strip('\"') CURRENT_REF = os.getenv("CURRENT_REF").strip('\"') PREVIOUS_REF = os.getenv("PREVIOUS_REF").strip('\"') JIRA_SERVER = os.getenv("JIRA_SERVER", "https://heartex.atlassian.net").strip('\"') JIRA_USERNAME = os.getenv("JIRA_USERNAME").strip('\"') JIRA_TOKEN = os.getenv("JIRA_TOKEN").strip('\"') JIRA_RN_FIELD = os.getenv("JIRA_RN_FIELD", "customfield_10064").strip('\"') JIRA_PROJECTS = os.getenv("JIRA_PROJECTS", "PLT,LEAP,OPTIC,DIA").split(",") JIRA_RELEASE_PREFIX = os.getenv("JIRA_RELEASE_PREFIX", None).strip('\"') GH_REPO = os.getenv("GH_REPO", "").strip('\"') GH_TOKEN = os.getenv("GH_TOKEN").strip('\"') # https://github.com/settings/tokens/new LAUNCHDARKLY_SDK_KEY = os.getenv("LAUNCHDARKLY_SDK_KEY", '').strip('\"') LAUNCHDARKLY_ENVIRONMENT = os.getenv("LAUNCHDARKLY_ENVIRONMENT", '').strip('\"') HELM_CHART_REPO = os.getenv("HELM_CHART_REPO", None) HELM_CHART_PATH = os.getenv("HELM_CHART_PATH", None) OUTPUT_FILE_MD = os.getenv("OUTPUT_FILE_MD", 'output.md') OUTPUT_FILE_JSON = os.getenv("OUTPUT_FILE_JSON", 'output.json') WORKFLOW_RUN_LINK = os.getenv("WORKFLOW_RUN_LINK", '') COMMIT_LABEL_MAP = { 'feat': 'New Features', 'fix': 'Bug Fixes', 'perf': 'Performance Improvements', 'refactor': 'Code Refactoring', 'docs': 'Documentation' } DEFAULT_LABEL = "Other" LABEL_SORT = [ "New Feature", "Improvement", "Customer Bug Fix", "Bug Fix", "Refactor", "Research", DEFAULT_LABEL, ] github_client = Github(GH_TOKEN) github_repo = github_client.get_repo(GH_REPO) jira_client = JIRA(JIRA_SERVER, basic_auth=(JIRA_USERNAME, JIRA_TOKEN)) FEATURE_FLAGS = {} def ff_is_on(ff: dict) -> bool: return ff.get('fallthrough').get('variation') == 0 if ff.get('on') else ff.get('offVariation') == 0 def ff_status(ff: dict) -> str: is_on = ff_is_on(ff) is_on_text = "On" if is_on else "Off" options = [] if ff.get('rules'): options.append('rules') if ff.get('targets'): options.append('targets') if options: options_text = ' and '.join(options) return f'{is_on_text} with {options_text}' return is_on_text def ff_link(ff: dict) -> str: key = ff.get('key') return f'https://app.launchdarkly.com/default/{LAUNCHDARKLY_ENVIRONMENT}/features/{key}/targeting' class JiraIssue: pr = None def __init__(self, issue_number: str, pr: int = None): self.type = "Jira Issue" self.pr = pr issue = jira_client.issue(issue_number) self.key = str(issue) self.status = str(issue.fields.status) self.label = DEFAULT_LABEL self.summary = str(issue.raw['fields']['summary']) self.release_note = issue.get_field(JIRA_RN_FIELD) self.desc = self.release_note if self.release_note else self.summary self.link = f"{JIRA_SERVER}/browse/{self.key}" self.releases_tags = [] self.ffs = self.get_ffs() def set_releases_tags(self, tags: list[str]): pass def __str__(self): return f"<{self.link}|[{self.key}]>: {self.desc} -- *{self.status}*" def __key(self): return self.key def __dict__(self): return { "desc": self.desc, "link": self.link, "key": self.key, "status": self.status, "pr": self.pr, "ffs": self.ffs, } def get_ffs(self): ff_key = self.key.lower().replace('-', '_') result = [] for name, ff in FEATURE_FLAGS.items(): if ff_key in name: key = ff.get('key') on = ff_is_on(ff) link = ff_link(ff) status = ff_status(ff) result.append( { 'key': key, 'on': on, 'link': link, 'status': status, } ) return result TASK_CACHE = {} def get_task(task_number: str, pr: int = None) -> JiraIssue or None: if task_number in TASK_CACHE.keys(): return TASK_CACHE.get(task_number) try: task = JiraIssue(task_number, pr) TASK_CACHE[task_number] = task return task except Exception as e: print(f'Could not find Issue {task_number} in Jira: {e}') return None def get_jira_release(project: str, version: str) -> jira.client.Version or None: jira_project_versions = jira_client.project_versions(project=project) jira_sorted_project_versions = sorted(jira_project_versions, key=lambda x: x.name, reverse=True) return next((e for e in jira_sorted_project_versions if version in e.name), None) def get_jira_release_issues(project_id: str, release_id: str) -> list[JiraIssue]: issues = jira_client.search_issues( f"project = {project_id} AND fixVersion = {release_id} ORDER BY priority DESC, key ASC") tasks = set() for issue in issues: if task := get_task(issue.key): tasks.add(task) return list(tasks) def get_github_release(previous_ref: str, current_ref: str): return github_repo.compare(previous_ref, current_ref) def get_github_release_tasks(commits) -> list[JiraIssue]: tasks = set() for commit in commits: message_first_line = commit.commit.message.split("\n")[0] if (match := re.match(COMMIT_PATTERN, message_first_line)) is not None: label = match.group(1) if label in COMMIT_LABEL_MAP.keys(): task_key = match.group(2) pr = None try: pr = int(match.group(5)) except Exception as e: print(f'Could not parse pr from "{message_first_line}": {str(e)}') if task := get_task(task_key, pr): tasks.add(task) return list(tasks) def get_feature_flags() -> dict: if LAUNCHDARKLY_SDK_KEY: response = requests.get( url="https://sdk.launchdarkly.com/sdk/latest-all", headers={ "Authorization": LAUNCHDARKLY_SDK_KEY, }, timeout=30, ) return response.json().get('flags', {}) return {} def missing_tasks(left: list[JiraIssue], right: list[JiraIssue]) -> list[JiraIssue]: r_keys = [x.key for x in right] missing = [task for task in left if task.key not in r_keys] missing_sorted = sorted(missing, key=lambda x: int(x.key.split('-')[-1])) return missing_sorted def sort_task_by_label(tasks: list[JiraIssue]) -> dict[str, list[JiraIssue]]: result = {} for task in tasks: result[task.label] = result.get(task.label, []) + [task] return result def render_link_md(text: str, link: str) -> str: return f"[{text}]({link})" def render_tasks_md(tasks: list[JiraIssue]) -> list[str]: result = [] for task in tasks: summary = task.desc.replace('\n', ' ') result.append(f'- {summary} {render_link_md(task.key, task.link)}') return result def render_ffs_md(ffs: list) -> list[str]: result = [] for ff in ffs: key = ff.get('key') link = ff_link(ff) result.append(f'- {render_link_md(key, link)}') return result def render_add_quote_md(lines: list[str]) -> list[str]: return [f'> {line}' for line in lines] def render_add_spoiler_md(title: str, lines: list[str]) -> list[str]: result = ["
", f"{title}", ""] result.extend(lines) result.append("") result.append("
") return result def render_add_header_md(title: str, lines: list[str]) -> list[str]: result = [f"### {title}"] result.extend(lines) result.append('') return result def render_output_md( gh_release, jira_releases_issues_jql_url, sorted_release_tasks: dict[str, list[JiraIssue]], missing_in_gh: list[JiraIssue], missing_in_tracker: list[JiraIssue], missing_release_note_field: list[JiraIssue], turned_off_feature_flags: list, helm_chart_version: str = None, ) -> str: release_notes_lines = [] if helm_chart_version: release_notes_lines.append(f'Helm Chart version: {helm_chart_version}') for label, tasks in sorted(sorted_release_tasks.items(), key=lambda x: LABEL_SORT.index(x[0]) if x[0] in LABEL_SORT else 100): release_notes_lines.extend( render_add_header_md( label, render_tasks_md(tasks) ) ) comment = [] comment.append(f'Full Changelog: [{PREVIOUS_REF}...{RELEASE_VERSION}]({gh_release.html_url})') comment.append( f'This changelog was updated in response to a push of {CURRENT_REF} [Workflow run]({WORKFLOW_RUN_LINK})') comment.append('') if jira_releases_issues_jql_url: comment.append(f'[Jira Release {RELEASE_VERSION} Issues Filter]({jira_releases_issues_jql_url})') else: comment.append('Jira Release not found') if len(missing_in_tracker) == 0: comment.append('Release Notes are generated based on git log: No tasks found in Task Tracker.') else: comment.append('Release Notes are generated based on Task Tracker.') if missing_in_gh: comment.extend( render_add_spoiler_md( f'Missing in GitHub release ({len(missing_in_gh)})', render_tasks_md(missing_in_gh) ) ) if missing_in_tracker: comment.extend( render_add_spoiler_md( f'Missing in Task Tracker release ({len(missing_in_tracker)})', render_tasks_md(missing_in_tracker) ) ) if missing_release_note_field: comment.extend( render_add_spoiler_md( f'Missing Release note field ({len(missing_release_note_field)})', render_tasks_md(missing_release_note_field) ) ) if turned_off_feature_flags: comment.extend( render_add_spoiler_md( f'Turned off Feature Flags ({len(turned_off_feature_flags)})', render_ffs_md(turned_off_feature_flags) ) ) comment.append('') comment.append('**ALL LINES STARTING FROM QUOTE WILL BE IGNORED**') release_notes_lines.extend( render_add_quote_md(comment) ) return '\n'.join(release_notes_lines) def render_output_json( sorted_release_tasks: dict[str, list[JiraIssue]], ) -> dict: sorted_release_tasks_json = {} for label, tasks in sorted_release_tasks.items(): sorted_release_tasks_json[label] = [t.__dict__() for t in tasks] result = { 'sorted_release_tasks': sorted_release_tasks_json } return result def get_helm_chart_version(repo: str, path: str) -> str or None: chart_repo = github_client.get_repo(repo) content = chart_repo.get_contents(path) version_regexp = re.compile(r'version:\s*(.*)') match = re.search(version_regexp, content.decoded_content.decode('utf-8')) return match.group(1) def main(): gh_release = get_github_release(PREVIOUS_REF, CURRENT_REF) print(f"Compare url: {gh_release.html_url}") print(f"Ahead by {gh_release.ahead_by}") print(f"Behind by {gh_release.behind_by}") print(f"Merge base commit: {gh_release.merge_base_commit}") print(f"Commits: {gh_release.commits}") global FEATURE_FLAGS try: FEATURE_FLAGS = get_feature_flags() except Exception as e: print(f'Failed to fetch Feature Flags: {e}') gh_release_tasks = get_github_release_tasks(gh_release.commits) jira_release_issues = [] jira_releases_urls = [] jira_releases_issues_jql_url = None for jira_project in JIRA_PROJECTS: jira_fix_version = f"{JIRA_RELEASE_PREFIX}/{RELEASE_VERSION}" jira_release = get_jira_release(jira_project, jira_fix_version) if jira_release: jira_release_url = f"{JIRA_SERVER}/projects/{jira_project}/versions/{jira_release.id}" jira_releases_urls.append(jira_release_url) print(f"Found Jira Release {jira_release.name} in project {jira_project}: {jira_release_url}") jira_release_issues = get_jira_release_issues(jira_release.projectId, jira_release.id) jira_release_issues.extend(jira_release_issues) issues_jql = quote(f"fixversion=\"{jira_fix_version}\" ORDER BY created DESC") jira_releases_issues_jql_url = f"{JIRA_SERVER}/issues/?jql={issues_jql}" tracker_release_tasks = jira_release_issues if tracker_release_tasks: print(f"{len(tracker_release_tasks)} tasks found in Task Tracker") print("Using Task Tracker as a source") sorted_release_tasks = sort_task_by_label(tracker_release_tasks) missing_in_gh = missing_tasks(tracker_release_tasks, gh_release_tasks) missing_in_tracker = missing_tasks(gh_release_tasks, tracker_release_tasks) missing_release_note_field = [x for x in tracker_release_tasks if not x.release_note] else: print("No tasks found in Task Tracker") print("Using GitHub as a source") sorted_release_tasks = sort_task_by_label(gh_release_tasks) missing_in_gh = [] missing_in_tracker = [] missing_release_note_field = [x for x in tracker_release_tasks if not x.release_note] turned_off_feature_flags = [ff for name, ff in FEATURE_FLAGS.items() if not ff_is_on(ff)] helm_chart_version = None if HELM_CHART_REPO and HELM_CHART_PATH: try: helm_chart_version = get_helm_chart_version(HELM_CHART_REPO, HELM_CHART_PATH) except Exception as e: print(f'Failed to fetch Helm Chart Version: {e}') output_md = render_output_md( gh_release, jira_releases_issues_jql_url, sorted_release_tasks, missing_in_gh, missing_in_tracker, missing_release_note_field, turned_off_feature_flags, helm_chart_version=helm_chart_version, ) if OUTPUT_FILE_MD: with open(OUTPUT_FILE_MD, 'w') as f: print(f"Creating a markdown output file: '{OUTPUT_FILE_MD}'") f.write(output_md) else: print("OUTPUT_FILE_MD is not specified") print(output_md) output_json = render_output_json(sorted_release_tasks) if OUTPUT_FILE_JSON: with open(OUTPUT_FILE_JSON, 'w') as f: print(f"Creating a json output file: '{OUTPUT_FILE_JSON}'") json.dump(output_json, f) else: print("OUTPUT_FILE_JSON is not specified") print(output_json) if __name__ == "__main__": main()