From 2010c278f5fd93f85716443146a2707c182eb0de Mon Sep 17 00:00:00 2001 From: John Strunk Date: Tue, 30 Apr 2024 20:01:25 +0000 Subject: [PATCH] Add issue cache & fix type errors Signed-off-by: John Strunk --- Pipfile | 1 + Pipfile.lock | 61 +++++++++++++++++---------- bot.py | 3 +- jiraissues.py | 101 +++++++++++++++++++++++++++++---------------- summarize_issue.py | 3 +- summarizer.py | 13 +++--- 6 files changed, 117 insertions(+), 65 deletions(-) diff --git a/Pipfile b/Pipfile index df334d8..a0bde99 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,7 @@ pylint = "*" pytest = "*" pytest-pylint = "*" pytest-mypy = "*" +types-requests = "*" [requires] python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index 323219e..3ae7aea 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5093da5aeff45b16519a4c6a0cee044c6a132c9778202533d0f36974a30f22f9" + "sha256": "2240e2eac5e2dab81b1d49f7e74b4feb7d3ecd63398d1591ff5b9a56ecaffe71" }, "pipfile-spec": 6, "requires": { @@ -260,11 +260,11 @@ }, "dataclasses-json": { "hashes": [ - "sha256:73696ebf24936560cca79a2430cbc4f3dd23ac7bf46ed17f38e5e5e7657a6377", - "sha256:f90578b8a3177f7552f4e1a6e535e84293cd5da421fcce0642d49c0d7bdf8df2" + "sha256:1c287594d9fcea72dc42d6d3836cf14848c2dc5ce88f65ed61b36b57f515fe26", + "sha256:f49c77aa3a85cac5bf5b7f65f4790ca0d2be8ef4d92c75e91ba0103072788a39" ], "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==0.6.4" + "version": "==0.6.5" }, "deprecated": { "hashes": [ @@ -505,19 +505,19 @@ }, "langchain-community": { "hashes": [ - "sha256:96e9a807d9b4777820df5a970996f6bf3ad5632137bf0f4d863bd832bdeb2b0f", - "sha256:bc13b21a44bbfca01bff8b35c10a26d71485b57c1d284f499b577ba6e1a5d84a" + "sha256:0f8726d9f8e1f369ae1b0c7ec738403063009a78ecb58860d21e5388e238ff0c", + "sha256:296c47dcddf8c3c565f41240dc21421620f309ae24db762a5bdaf0c19cbb01ef" ], "markers": "python_version < '4.0' and python_full_version >= '3.8.1'", - "version": "==0.0.34" + "version": "==0.0.35" }, "langchain-core": { "hashes": [ - "sha256:526532c1af279a9e2debe7a4e143ba6e980cf90b5ab2e0991c2230ee04c693e2", - "sha256:91eff20de0bcf5f025e1d8c4582cb597a9c17527965eb03b314486e7c834e7df" + "sha256:d97d6927a4b22acbc2d0e731b3580890551256fa5dde775ef6beb72beb1a6015", + "sha256:ebf12ca25cbdfedd8a61dbdb60f47283bb1bdfc39b5f01d3b76bb36fdbe4a1e8" ], "markers": "python_version < '4.0' and python_full_version >= '3.8.1'", - "version": "==0.1.45" + "version": "==0.1.47" }, "langchain-text-splitters": { "hashes": [ @@ -529,11 +529,11 @@ }, "langsmith": { "hashes": [ - "sha256:9fd22df8c689c044058536ea5af66f5302067e7551b60d7a335fede8d479572b", - "sha256:a81e9809fcaa277bfb314d729e58116554f186d1478fcfdf553b1c2ccce54b85" + "sha256:4518e269b9a0e10197550f050b6518d1276fe68732f7b8579b3e1302b8471d29", + "sha256:f767fddb13c794bea7cc827a77f050a8a1c075ab1d997eb37849b975b0eef1b0" ], "markers": "python_version < '4.0' and python_full_version >= '3.8.1'", - "version": "==0.1.50" + "version": "==0.1.52" }, "marshmallow": { "hashes": [ @@ -1297,11 +1297,11 @@ }, "filelock": { "hashes": [ - "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f", - "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4" + "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", + "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" ], "markers": "python_version >= '3.8'", - "version": "==3.13.4" + "version": "==3.14.0" }, "iniconfig": { "hashes": [ @@ -1322,11 +1322,11 @@ }, "ipython": { "hashes": [ - "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1", - "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d" + "sha256:010db3f8a728a578bb641fdd06c063b9fb8e96a9464c63aec6310fbcb5e80501", + "sha256:d7bf2f6c4314984e3e02393213bab8703cf163ede39672ce5918c51fe253a2a3" ], "markers": "python_version >= '3.10'", - "version": "==8.23.0" + "version": "==8.24.0" }, "isort": { "hashes": [ @@ -1529,12 +1529,12 @@ }, "pytest": { "hashes": [ - "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7", - "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044" + "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233", + "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.1.1" + "version": "==8.2.0" }, "pytest-mypy": { "hashes": [ @@ -1704,6 +1704,15 @@ "markers": "python_version >= '3.8'", "version": "==5.14.3" }, + "types-requests": { + "hashes": [ + "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1", + "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.31.0.20240406" + }, "typing-extensions": { "hashes": [ "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", @@ -1712,6 +1721,14 @@ "markers": "python_version >= '3.8'", "version": "==4.11.0" }, + "urllib3": { + "hashes": [ + "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", + "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.1" + }, "wcwidth": { "hashes": [ "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", diff --git a/bot.py b/bot.py index 4a2dda3..f903508 100755 --- a/bot.py +++ b/bot.py @@ -9,13 +9,14 @@ from datetime import datetime import requests -from atlassian import Jira +from atlassian import Jira # type: ignore from jiraissues import Issue from summarizer import get_issues_to_summarize, summarize_issue def main(): + """Main function for the bot.""" parser = argparse.ArgumentParser(description="Summarizer bot") parser.add_argument( "-d", diff --git a/jiraissues.py b/jiraissues.py index 8e152f2..e52b6a4 100644 --- a/jiraissues.py +++ b/jiraissues.py @@ -78,27 +78,7 @@ class Issue: # pylint: disable=too-many-instance-attributes Represents a Jira issue as a proper object. """ - client: Jira - """The Jira client to use for API calls.""" - key: str - """The key of the issue.""" - summary: str - """The summary of the issue.""" - description: str - """The description of the issue.""" - issue_type: str - """The type of the issue.""" - labels: List[str] - """The labels on the issue.""" - resolution: str - """The resolution of the issue.""" - status: str - """The status of the issue.""" - _changelog: Optional[List[ChangelogEntry]] - _comments: Optional[List[Comment]] - _related: Optional[List[RelatedIssue]] - - def __init__(self, client: Jira, issue_key: str): + def __init__(self, client: Jira, issue_key: str) -> None: self.client = client self.key = issue_key @@ -114,27 +94,27 @@ def __init__(self, client: Jira, issue_key: str): data = _check(client.issue(issue_key, fields=",".join(fields))) # Populate the fields - self.summary = data["fields"]["summary"] - self.description = data["fields"]["description"] - self.issue_type = data["fields"]["issuetype"]["name"] - self.status = data["fields"]["status"]["name"] - self.labels = data["fields"]["labels"] - self.resolution = ( + self.summary: str = data["fields"]["summary"] + self.description: str = data["fields"]["description"] + self.issue_type: str = data["fields"]["issuetype"]["name"] + self.status: str = data["fields"]["status"]["name"] + self.labels: List[str] = data["fields"]["labels"] + self.resolution: str = ( data["fields"]["resolution"]["name"] if data["fields"]["resolution"] else "Unresolved" ) - self._changelog = None - self._comments = None - self._related = None - _logger.info(f"Retrieved issue: {self.key} - {self.summary}") + self._changelog: Optional[List[ChangelogEntry]] = None + self._comments: Optional[List[Comment]] = None + self._related: Optional[List[RelatedIssue]] = None + _logger.info("Retrieved issue: %s - %s", self.key, self.summary) def __str__(self) -> str: return f"{self.key}: {self.summary} ({self.status}/{self.resolution})" def _fetch_changelog(self) -> List[ChangelogEntry]: """Fetch the changelog from the API.""" - _logger.debug(f"Retrieving changelog for {self.key}") + _logger.debug("Retrieving changelog for %s", self.key) log = _check(self.client.get_issue_changelog(self.key, start=0, limit=1000)) items: List[ChangelogEntry] = [] for entry in log["histories"]: @@ -167,7 +147,7 @@ def changelog(self) -> List[ChangelogEntry]: def _fetch_comments(self) -> List[Comment]: """Fetch the comments from the API.""" - _logger.debug(f"Retrieving comments for {self.key}") + _logger.debug("Retrieving comments for %s", self.key) comments = _check(self.client.issue(self.key, fields="comment"))["fields"][ "comment" ]["comments"] @@ -198,7 +178,7 @@ def _fetch_related(self) -> List[RelatedIssue]: "customfield_12313140", "customfield_12318341", ] - _logger.debug(f"Retrieving related links for {self.key}") + _logger.debug("Retrieving related links for %s", self.key) data = _check(self.client.issue(self.key, fields=",".join(fields))) # Get the related issues related: List[RelatedIssue] = [] @@ -293,10 +273,11 @@ def update_description(self, new_description: str) -> None: Parameters: - new_description: The new description to set. """ - _logger.info(f"Sending updated description for {self.key} to server") + _logger.info("Sending updated description for %s to server", self.key) fields = {"description": new_description} self.client.update_issue_field(self.key, fields) # type: ignore self.description = new_description + issue_cache.remove(self.key) # Invalidate any cached copy def _check(response: Any) -> dict: @@ -312,3 +293,53 @@ def _check(response: Any) -> dict: if isinstance(response, dict): return response raise ValueError(f"Unexpected response: {response}") + + +class IssueCache: + """ + A cache of Jira issues to avoid fetching the same issue multiple times. + """ + + def __init__(self) -> None: + self._cache: dict[str, Issue] = {} + self.hits = 0 + self.tries = 0 + + def get_issue(self, client: Jira, key: str) -> Issue: + """ + Get an issue from the cache, or fetch it from the server if it's not + already cached. + + Parameters: + - client: The Jira client to use for fetching the issue. + - key: The key of the issue to fetch. + + Returns: + The issue object. + """ + self.tries += 1 + if key not in self._cache: + _logger.debug("Cache miss: %s", key) + self._cache[key] = Issue(client, key) + else: + self.hits += 1 + _logger.debug("Cache hit: %s", key) + return self._cache[key] + + def remove(self, key: str) -> None: + """ + Remove an Issue from the cache. + + Parameters: + - key: The key of the issue to remove. + """ + if key in self._cache: + del self._cache[key] + + def clear(self) -> None: + """Clear the cache.""" + self._cache = {} + + +# The global cache of issues +issue_cache = IssueCache() diff --git a/summarize_issue.py b/summarize_issue.py index 0d4068c..f3889f5 100755 --- a/summarize_issue.py +++ b/summarize_issue.py @@ -6,13 +6,14 @@ import logging import os -from atlassian import Jira +from atlassian import Jira # type: ignore from jiraissues import Issue from summarizer import summarize_issue def main(): + """Main function""" parser = argparse.ArgumentParser(description="Summarize a JIRA issue") parser.add_argument( "-d", diff --git a/summarizer.py b/summarizer.py index 1a3f5c0..2682a2b 100644 --- a/summarizer.py +++ b/summarizer.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import List, Tuple -from atlassian import Jira +from atlassian import Jira # type: ignore from genai import Client, Credentials from genai.extensions.langchain import LangChainInterface from genai.schema import DecodingMethod, TextGenerationParameters @@ -34,6 +34,7 @@ _WRAP_COLUMN = 78 +# pylint: disable=too-many-locals def summarize_issue( issue: Issue, max_depth: int = 0, @@ -58,11 +59,11 @@ def summarize_issue( A string containing the summary """ - _logger.info(f"Summarizing {issue.key}...") + _logger.info("Summarizing %s...", issue.key) # If the current summary is up-to-date and we're not asked to regenerate it, # return what's there if not regenerate and is_summary_current(issue): - _logger.debug(f"Summary for {issue.key} is current, using that.") + _logger.debug("Summary for %s is current, using that.", issue.key) return get_aisummary(issue.description) # if we have not reached max-depth, summarize the child issues for inclusion in this summary @@ -151,8 +152,8 @@ def summarize_issue( Here is the summary in a few sentences: """ - _logger.info(f"Summarizing {issue.key} via LLM") - _logger.debug(f"Prompt:\n{llm_prompt}") + _logger.info("Summarizing %s via LLM", issue.key) + _logger.debug("Prompt:\n%s", llm_prompt) chat = _chat_model() summary = chat.invoke(llm_prompt).strip() @@ -310,7 +311,7 @@ def get_issues_to_summarize( limit=50, fields="key,updated", ) - if type(result) is not dict: + if not isinstance(result, dict): return [] keys = [issue["key"] for issue in result["issues"]] return keys