diff --git a/docs/contribute.rst b/docs/contribute.rst index 5d9accf268..2141505c29 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -855,6 +855,38 @@ prioritized issues. .. _backlog: https://github.com/orgs/teemtee/projects/1/views/1 +Sync +------------------------------------------------------------------ + +The ``scripts/sprint-overview`` script lists all issues and pull +requests in a GitHub Project sprint together with their story +points. It can also output the data in YAML format for further +processing: + + .. code-block:: bash + + ./scripts/sprint-overview --sprint 'Sprint 11' + ./scripts/sprint-overview --sprint 'Sprint 11' --yaml + +The ``scripts/sprint-sync`` script synchronizes a Jira sprint +with GitHub sprint items. It reads the YAML output from +``sprint-overview``, removes Jira items not present in the +GitHub sprint and adds missing items into the Jira sprint: + + .. code-block:: bash + + ./scripts/sprint-overview --sprint 'Sprint 11' --yaml \ + | ./scripts/sprint-sync --sprint 'Sprint 11' + +Use ``--dry`` to preview changes without modifying the Jira +sprint: + + .. code-block:: bash + + ./scripts/sprint-overview --sprint 'Sprint 11' --yaml \ + | ./scripts/sprint-sync --sprint 'Sprint 11' --dry + + Release ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/scripts/common.py b/scripts/common.py new file mode 100644 index 0000000000..6e90663669 --- /dev/null +++ b/scripts/common.py @@ -0,0 +1,26 @@ +""" +Common data structures shared between sprint scripts. +""" + +import dataclasses +from typing import Optional + + +@dataclasses.dataclass +class Item: + """ + A sprint item (issue or pull request). + """ + + id: int + type: str + repo: str + status: str + size: Optional[int] + url: str + title: str + + def __str__(self) -> str: + size_str = f"[{self.size}]" if self.size is not None else "[-]" + identifier = f"{self.repo}#{self.id}" + return f"{identifier:<10} {size_str:>4} {self.title}" diff --git a/scripts/sprint-overview b/scripts/sprint-overview index 3ebb794e7b..5cc34a6b58 100755 --- a/scripts/sprint-overview +++ b/scripts/sprint-overview @@ -26,6 +26,8 @@ import jinja2 import requests from ruamel.yaml import YAML +from common import Item # isort: skip + # https://docs.github.com/en/rest/about-the-rest-api/breaking-changes GITHUB_API_URL = "https://api.github.com" GITHUB_API_VERSION = "2026-03-10" @@ -75,26 +77,6 @@ DISPLAY_TEMPLATE = jinja2.Template( ) -@dataclasses.dataclass -class Item: - """ - A sprint item (issue or pull request). - """ - - id: int - type: str - repo: str - status: str - size: Optional[int] - url: str - title: str - - def __str__(self) -> str: - size_str = f"[{self.size}]" if self.size is not None else "[-]" - identifier = f"{self.repo}#{self.id}" - return f"{identifier:<10} {size_str:>4} {self.title}" - - def github_api_get( path: str, params: Optional[dict[str, str]] = None, @@ -253,14 +235,14 @@ def main(sprint: str, output_yaml: bool) -> None: """ items = fetch_sprint_items(sprint) - if not items: - click.echo(f"No items found in sprint '{sprint}'.") - return - if output_yaml: YAML().dump([dataclasses.asdict(item) for item in items], sys.stdout) - else: + return + + if items: display_items(sprint, items) + else: + click.echo(f"No items found in sprint '{sprint}'.", err=True) if __name__ == "__main__": diff --git a/scripts/sprint-sync b/scripts/sprint-sync new file mode 100755 index 0000000000..9e5c16d57b --- /dev/null +++ b/scripts/sprint-sync @@ -0,0 +1,325 @@ +#!/usr/bin/python3 +# +# /// script +# dependencies = [ +# "click", +# "jinja2", +# "jira", +# "ruamel.yaml", +# ] +# /// + +""" +Sync a Jira sprint with GitHub sprint items. +""" + +import os +import re +import sys +from typing import IO, Any, Optional, cast + +import click +import jinja2 +from jira import JIRA +from jira.resources import Issue +from ruamel.yaml import YAML + +from common import Item # isort: skip + +JIRA_SERVER = "https://redhat.atlassian.net/" +JIRA_PROJECT = "TMT" +JIRA_BOARD_ID = 1411 + + +class SyncItem: + """ + Represents a sync decision for a single item. + """ + + TEMPLATE = jinja2.Template( + source="""\ + {{ key }} {{ summary }} + {{ decision }} {{ url }} +{% if duplicates %}\ + duplicates found: {{ duplicates | join(", ") }} +{% endif %}""" + ) + + COLORS = { + "keep": "blue", + "add ": "green", + "drop": "red", + "lost": "red", + "miss": "yellow", + } + + def __init__( + self, + key: str, + decision: str, + summary: str, + url: Optional[str] = None, + duplicates: Optional[list[str]] = None, + ) -> None: + self.key = key + self.decision = decision + self.summary = re.sub(r'^\[teemtee/[^\]]*\]\s*', '', summary) + self.url = url + self.duplicates = duplicates + + def show(self) -> None: + """ + Render the item, color decisions + """ + + click.echo( + self.TEMPLATE.render( + key=f"{self.key:<8}", + decision=click.style(f"{self.decision:<8}", fg=self.COLORS.get(self.decision)), + summary=self.summary, + url=self.url or "Upstream issue not found.", + duplicates=self.duplicates, + ) + ) + + +def connect_to_jira() -> JIRA: + """ + Connect to Jira using environment variables for authentication. + + :returns: an authenticated :py:class:`JIRA` client. + """ + + server = os.environ.get("JIRA_SERVER", JIRA_SERVER) + email = os.environ.get("JIRA_EMAIL") + token = os.environ.get("JIRA_TOKEN") + + if not email: + click.echo("Error: JIRA_EMAIL environment variable is not set.", err=True) + sys.exit(1) + + if not token: + click.echo("Error: JIRA_TOKEN environment variable is not set.", err=True) + sys.exit(1) + + return JIRA(server=server, basic_auth=(email, token)) + + +def fetch_jira_items(jira: JIRA, sprint_name: str) -> list[Issue]: + """ + Fetch all items from the given sprint. + + :param jira: authenticated Jira client. + :param sprint_name: name of the sprint. + :returns: list of Jira items + """ + + query = f'sprint = "{sprint_name}" ORDER BY key ASC' + items = jira.search_issues(query, maxResults=False, json_result=False) + assert isinstance(items, list) + click.echo(f"Fetched {len(items)} items.\n") + + return items + + +def load_github_items(source: IO[str]) -> list[Item]: + """ + Load GitHub sprint items in sprint-overview --yaml format. + + :param source: a file-like object to read YAML from. + :returns: a list of sprint items + """ + + data = YAML().load(source) + + if not isinstance(data, list): + click.echo(f"Invalid input data:\n{data}", err=True) + sys.exit(1) + + return [Item(**item) for item in cast(list[dict[str, Any]], data)] + + +def get_upstream_url(jira: JIRA, issue: Issue) -> Optional[str]: + """ + Get the "Upstream issue" remote link URL for a Jira issue. + + :param jira: authenticated Jira client. + :param issue: a Jira issue object. + :returns: the upstream URL, or ``None`` if not found. + """ + + for link in jira.remote_links(issue): + if link.object.title == "Upstream issue": + return link.object.url + + return None + + +def find_sprint_id(jira: JIRA, sprint_name: str) -> int: + """ + Find the sprint ID by name on the TMT board. + + :param jira: authenticated Jira client. + :param sprint_name: name of the sprint. + :returns: the sprint ID, or gives an error if not found. + """ + + for sprint in jira.sprints(JIRA_BOARD_ID): + if sprint.name == sprint_name: + return sprint.id + + click.echo(f"Sprint '{sprint_name}' not found.", err=True) + sys.exit(1) + + +def find_jira_issues( + jira: JIRA, + github_item: Item, +) -> list[Issue]: + """ + Find Jira issues that have a remote link matching the given GitHub item. + + First searches by remote link URL, then falls back to title search. + + :param jira: authenticated Jira client. + :param github_item: the GitHub :py:class:`Item` to search for. + :returns: a list of matching Jira issues sorted by key (lowest first). + """ + + def sort_by_key(results: list[Issue]) -> list[Issue]: + return sorted(results, key=lambda result: int(result.key.split("-")[-1])) + + # Search by remote link URL (this works only if already indexed, and + # rebuilding the index can take several days when restarted again) + query = f'project = "{JIRA_PROJECT}" AND remoteLinkUrl in ("{github_item.url}")' + results = jira.search_issues(query, maxResults=False, json_result=False) + assert isinstance(results, list) + if results: + return sort_by_key(results) + + # Fall back to title search and verify via remote links + title = github_item.title.replace('\\', '\\\\').replace("'", "\\'").replace('"', '') + query = f'project = "{JIRA_PROJECT}" AND summary ~ "{title}"' + candidates = jira.search_issues(query, maxResults=False, json_result=False) + assert isinstance(candidates, list) + + matched = [ + candidate + for candidate in candidates + if get_upstream_url(jira, candidate) == github_item.url + ] + return sort_by_key(matched) + + +def sync_sprint( + jira: JIRA, + sprint_id: int, + jira_items: list[Issue], + github_items: list[Item], + dry: bool = False, +) -> None: + """ + Sync Jira sprint with GitHub sprint items. + + Removes Jira issues not present in the GitHub sprint and adds + missing GitHub items into the Jira sprint. + + :param jira: authenticated Jira client. + :param sprint_id: ID of the sprint. + :param jira_items: list of Jira issues currently in the sprint. + :param github_items: list of GitHub project sprint items. + :param dry: if ``True``, only show what would be done without + making any changes. + """ + + github_items_by_url = {item.url: item for item in github_items} + urls_to_process = set(github_items_by_url.keys()) + + items_to_drop: list[str] = [] + items_to_add: list[str] = [] + + # Step 1: Check existing Jira items — keep or remove + for jira_item in jira_items: + url = get_upstream_url(jira, jira_item) + + if not url: + SyncItem(jira_item.key, "lost", jira_item.fields.summary).show() + elif url in urls_to_process: + urls_to_process.remove(url) + SyncItem(jira_item.key, "keep", jira_item.fields.summary, url).show() + else: + SyncItem(jira_item.key, "drop", jira_item.fields.summary, url).show() + items_to_drop.append(jira_item.key) + + # Step 2: Find missing GitHub items + for url in sorted(urls_to_process): + github_item = github_items_by_url[url] + jira_issues = find_jira_issues(jira, github_item) + + if jira_issues: + jira_item = jira_issues[0] + duplicates = [issue.key for issue in jira_issues[1:]] or None + SyncItem(jira_item.key, "add ", jira_item.fields.summary, url, duplicates).show() + items_to_add.append(jira_item.key) + else: + SyncItem("???", "miss", github_item.title, url).show() + + # Step 3: Apply changes + if not dry: + if items_to_drop: + jira.move_to_backlog(items_to_drop) + if items_to_add: + jira.add_issues_to_sprint(sprint_id, items_to_add) + + +@click.command() +@click.option( + "--sprint", + metavar="NAME", + required=True, + help="Name of the sprint, e.g. 'TMT Sprint 11'.", +) +@click.option( + "--dry", + is_flag=True, + default=False, + help="Only show what would be done without making any changes.", +) +@click.argument("yaml_file", default="-", type=click.File()) +def main(sprint: str, dry: bool, yaml_file: IO[str]) -> None: + """ + Sync a Jira sprint items with GitHub sprint items. + + Reads GitHub sprint items in YAML format (from sprint-overview --yaml) + from YAML_FILE or stdin and synchronizes them with the Jira sprint. + Removed items are dropped, new items are added, no status changes + are performed. + + Example usage: + + \b + ./sprint-overview --sprint 'Sprint 11' --yaml | ./sprint-sync --sprint 'Sprint 11' + + Authentication is configured via environment variables: + + \b + JIRA_SERVER ... Jira server URL, https://redhat.atlassian.net/ by default + JIRA_EMAIL .... User email for authentication + JIRA_TOKEN .... Personal access token + """ + + click.echo(f"Syncing sprint: {sprint}{' (dry mode)' if dry else ''}") + + # Sprint names in Jira are prefixed with 'TMT' + sprint_name = f"TMT {sprint}" + + jira = connect_to_jira() + + sprint_id = find_sprint_id(jira, sprint_name) + github_items = load_github_items(yaml_file) + jira_items = fetch_jira_items(jira, sprint_name) + sync_sprint(jira, sprint_id, jira_items, github_items, dry=dry) + + +if __name__ == "__main__": + main()