llama-github 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {llama_github-0.1.1 → llama_github-0.1.2}/CHANGELOG.md +19 -0
  2. {llama_github-0.1.1/llama_github.egg-info → llama_github-0.1.2}/PKG-INFO +20 -1
  3. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/data_retrieval/github_api.py +21 -0
  4. llama_github-0.1.2/llama_github/data_retrieval/github_entities.py +578 -0
  5. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_integration/github_auth_manager.py +79 -0
  6. llama_github-0.1.2/llama_github/utils.py +307 -0
  7. llama_github-0.1.2/llama_github/version.py +1 -0
  8. {llama_github-0.1.1 → llama_github-0.1.2/llama_github.egg-info}/PKG-INFO +20 -1
  9. {llama_github-0.1.1 → llama_github-0.1.2}/setup.cfg +1 -1
  10. llama_github-0.1.1/llama_github/data_retrieval/github_entities.py +0 -301
  11. llama_github-0.1.1/llama_github/utils.py +0 -98
  12. llama_github-0.1.1/llama_github/version.py +0 -1
  13. {llama_github-0.1.1 → llama_github-0.1.2}/LICENSE +0 -0
  14. {llama_github-0.1.1 → llama_github-0.1.2}/MANIFEST.in +0 -0
  15. {llama_github-0.1.1 → llama_github-0.1.2}/README.md +0 -0
  16. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/__init__.py +0 -0
  17. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/__init__.py +0 -0
  18. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/config.json +0 -0
  19. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/config.py +0 -0
  20. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/data_retrieval/__init__.py +0 -0
  21. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/__init__.py +0 -0
  22. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/feature_flags.py +0 -0
  23. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/insider_features.py +0 -0
  24. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_integration/__init__.py +0 -0
  25. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_rag.py +0 -0
  26. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/__init__.py +0 -0
  27. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/initial_load.py +0 -0
  28. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/llm_handler.py +0 -0
  29. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/logger.py +0 -0
  30. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/rag_processing/__init__.py +0 -0
  31. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/rag_processing/rag_processor.py +0 -0
  32. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/SOURCES.txt +0 -0
  33. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/dependency_links.txt +0 -0
  34. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/requires.txt +0 -0
  35. {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/top_level.txt +0 -0
  36. {llama_github-0.1.1 → llama_github-0.1.2}/setup.py +0 -0
  37. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_data_retrieval.py +0 -0
  38. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_github_auth_manager.py +0 -0
  39. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_initial_load.py +0 -0
  40. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_llm_handler.py +0 -0
  41. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_logger.py +0 -0
  42. {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_rag_processor.py +0 -0
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2024-10-09
9
+
10
+ ### Added
11
+ - New `get_pr_content` method in `Repository` class for comprehensive PR data retrieval
12
+ - Singleton pattern implementation for efficient PR data caching
13
+ - Support for LLM-assisted PR analysis and Q&A capabilities
14
+ - Automatic caching mechanism to reduce API calls and improve performance
15
+ - Threaded comment and review retrieval functionality
16
+
17
+ ### Changed
18
+ - Improved PR data fetching process to include metadata, file changes, and comments
19
+
20
+ ### Optimized
21
+ - Reduced API calls through intelligent caching of PR data
22
+
23
+ ### Developer Notes
24
+ - First call to `get_pr_content` fetches data from GitHub API, subsequent calls use cached data
25
+ - Cache automatically refreshes when PR is updated
26
+
8
27
  ## [0.1.1] - 2024-08-23
9
28
 
10
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: llama-github
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Llama-github is an open-source Python library that empowers LLM Chatbots, AI Agents, and Auto-dev Agents to conduct Retrieval from actively selected GitHub public projects. It Augments through LLMs and Generates context for any coding question, in order to streamline the development of sophisticated AI-driven applications.
5
5
  Home-page: https://github.com/JetXu-LLM/llama-github
6
6
  Author: Jet Xu
@@ -133,6 +133,25 @@ All notable changes to this project will be documented in this file.
133
133
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
134
134
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
135
135
 
136
+ ## [0.1.2] - 2024-10-09
137
+
138
+ ### Added
139
+ - New `get_pr_content` method in `Repository` class for comprehensive PR data retrieval
140
+ - Singleton pattern implementation for efficient PR data caching
141
+ - Support for LLM-assisted PR analysis and Q&A capabilities
142
+ - Automatic caching mechanism to reduce API calls and improve performance
143
+ - Threaded comment and review retrieval functionality
144
+
145
+ ### Changed
146
+ - Improved PR data fetching process to include metadata, file changes, and comments
147
+
148
+ ### Optimized
149
+ - Reduced API calls through intelligent caching of PR data
150
+
151
+ ### Developer Notes
152
+ - First call to `get_pr_content` fetches data from GitHub API, subsequent calls use cached data
153
+ - Cache automatically refreshes when PR is updated
154
+
136
155
  ## [0.1.1] - 2024-08-23
137
156
 
138
157
  ### Added
@@ -5,6 +5,7 @@ from llama_github.logger import logger
5
5
  from llama_github.github_integration.github_auth_manager import ExtendedGithub
6
6
  from llama_github.config.config import config
7
7
  import re
8
+ from typing import Any, Dict, List
8
9
 
9
10
 
10
11
  class GitHubAPIHandler:
@@ -81,6 +82,26 @@ class GitHubAPIHandler:
81
82
  file_content = repository_obj.get_file_content(
82
83
  code_search_result['path'])
83
84
  return repository_obj, file_content
85
+
86
+ async def get_pr_files(self, repo: Repository, pr_number: int) -> List[Dict[str, Any]]:
87
+ url = f"{self.base_url}/repos/{repo.full_name}/pulls/{pr_number}/files"
88
+ headers = {"Authorization": f"token {self.token}"}
89
+ async with self.session.get(url, headers=headers) as response:
90
+ if response.status == 200:
91
+ return await response.json()
92
+ else:
93
+ logger.error(f"Failed to get PR files: {response.status}")
94
+ return []
95
+
96
+ async def get_pr_comments(self, repo: Repository, pr_number: int) -> List[Dict[str, Any]]:
97
+ url = f"{self.base_url}/repos/{repo.full_name}/issues/{pr_number}/comments"
98
+ headers = {"Authorization": f"token {self.token}"}
99
+ async with self.session.get(url, headers=headers) as response:
100
+ if response.status == 200:
101
+ return await response.json()
102
+ else:
103
+ logger.error(f"Failed to get PR comments: {response.status}")
104
+ return []
84
105
 
85
106
  def search_code(self, query, repo_full_name=None):
86
107
  """
@@ -0,0 +1,578 @@
1
+ from github import GithubException
2
+ from threading import Lock, Event, Thread
3
+ from datetime import datetime, timezone
4
+
5
+ from typing import Optional, Dict, Any, List
6
+ from llama_github.logger import logger
7
+ from llama_github.github_integration.github_auth_manager import ExtendedGithub
8
+ from llama_github.utils import DiffGenerator
9
+
10
+ import time
11
+ import re
12
+ import json
13
+ from dateutil import parser
14
+ from datetime import timezone
15
+ import base64
16
+
17
+ from llama_github.utils import DiffGenerator, CodeAnalyzer
18
+
19
+ class Repository:
20
+ def __init__(self, full_name, github_instance: ExtendedGithub, **kwargs):
21
+ """
22
+ Initializes a Repository instance with details and a GitHub instance for API calls.
23
+
24
+ :param id: The ID of the repository.
25
+ :param name: The name of the repository.
26
+ :param full_name: The full name of the repository (e.g., 'octocat/Hello-World').
27
+ :param description: The description of the repository.
28
+ :param html_url: The HTML URL of the repository.
29
+ :param stargazers_count: The count of stargazers of the repository.
30
+ :param watchers_count: The count of watchers of the repository.
31
+ :param language: The primary language of the repository.
32
+ :param forks_count: The count of forks of the repository.
33
+ :param github_instance: Authenticated instance of a Github client.
34
+ """
35
+
36
+ self._github = github_instance
37
+ self.full_name = full_name
38
+
39
+ self.id = kwargs.get("id")
40
+ self.name = kwargs.get("name")
41
+ self.description = kwargs.get("description")
42
+ self.html_url = kwargs.get("html_url")
43
+ self.stargazers_count = kwargs.get("stargazers_count")
44
+ self.language = kwargs.get("language")
45
+ self.default_branch = kwargs.get("default_branch")
46
+ self.updated_at = kwargs.get("updated_at")
47
+ self.watchers_count = None
48
+
49
+ self.creation_time = datetime.now(timezone.utc)
50
+ self.last_read_time = datetime.now(timezone.utc)
51
+
52
+ self._repo = None
53
+
54
+ # Locking for write action
55
+ self._structure_lock = Lock()
56
+ self._file_contents_lock = Lock()
57
+ self._issue_lock = Lock()
58
+ self._pr_lock = Lock()
59
+ self._readme_lock = Lock()
60
+ self._repo_lock = Lock()
61
+
62
+ if (self.id is None) or \
63
+ (self.name is None) or \
64
+ (self.full_name is None) or \
65
+ (self.html_url is None) or \
66
+ (self.stargazers_count is None) or \
67
+ (self.default_branch is None) or \
68
+ (self.updated_at is None):
69
+ self.get_repo()
70
+
71
+ self._structure = None # Singleton pattern for repository structure
72
+ self._file_contents = {} # Singleton pattern for file contents
73
+ self._issues = {} # Singleton pattern for file contents
74
+ self._readme = None # Singleton pattern for README content
75
+ self._prs = {} # Singleton pattern for PR content
76
+
77
+ def update_last_read_time(self):
78
+ self.last_read_time = datetime.now(timezone.utc)
79
+
80
+ def get_readme(self) -> str:
81
+ """
82
+ Retrieves the README content of the repository using a singleton design pattern.
83
+ """
84
+ if self._readme is None: # Check if README content has already been fetched
85
+ with self._readme_lock: # Locking for write action
86
+ if self._readme is None: # Check if README content has already been fetched after get lock
87
+ try:
88
+ readme = self.repo.get_readme()
89
+ self._readme = readme.decoded_content.decode("utf-8")
90
+ except GithubException as e:
91
+ logger.exception(
92
+ f"Error getting README for repository {self.full_name}:")
93
+ self._readme = None
94
+ self.update_last_read_time()
95
+ return self._readme
96
+
97
+ @property
98
+ def repo(self):
99
+ return self.get_repo()
100
+
101
+ def get_repo(self):
102
+ """
103
+ Retrieves the Github Repo object of the repository using a singleton design pattern.
104
+ """
105
+ if self._repo is None: # Check if repo object has already been fetched
106
+ with self._repo_lock: # Locking for write action
107
+ if self._repo is None:
108
+ try:
109
+ self._repo = self._github.get_repo(self.full_name)
110
+ self.id = self._repo.id
111
+ self.name = self._repo.name
112
+ self.description = self._repo.description
113
+ self.html_url = self._repo.html_url
114
+ self.stargazers_count = self._repo.stargazers_count
115
+ self.watchers_count = self._repo.subscribers_count
116
+ self.language = self._repo.language
117
+ self.default_branch = self._repo.default_branch
118
+ self.updated_at = self._repo.updated_at
119
+ except GithubException as e:
120
+ logger.exception(
121
+ f"Error retrieving repository '{self.full_name}':")
122
+ return None
123
+ self.update_last_read_time()
124
+ return self._repo
125
+
126
+ def get_structure(self, path="/") -> dict:
127
+ """
128
+ Retrieves the structure of the repository using a singleton design pattern.
129
+ """
130
+ if self._structure is None: # Check if structure has already been fetched
131
+ with self._structure_lock: # Locking for write action
132
+ if self._structure is None: # Check if structure has already been fetched after get lock
133
+ try:
134
+ self._structure = self._github.get_repo_structure(
135
+ self.full_name, self.default_branch)
136
+ except GithubException as e:
137
+ logger.exception(
138
+ f"Error getting structure for repository {self.full_name}:")
139
+ self._structure = None
140
+ self.update_last_read_time()
141
+ return self._structure
142
+
143
+ def get_file_content(self, file_path: str, sha: Optional[str] = None) -> Optional[str]:
144
+ """
145
+ Retrieves the content of a file using a singleton design pattern with improved encoding handling.
146
+
147
+ This method first checks if the file content is already cached. If not, it fetches the content
148
+ from the GitHub repository, handles potential encoding issues, and caches the result.
149
+
150
+ :param file_path: The path to the file in the repository.
151
+ :param sha: The commit SHA. If None, the latest version is fetched.
152
+ :return: The file content as a string or None if not found or on error.
153
+ """
154
+ file_key = f"{file_path}/{sha}" if sha is not None else file_path
155
+
156
+ if file_key not in self._file_contents: # Check if file content has already been fetched
157
+ with self._file_contents_lock: # Locking for thread-safe write action
158
+ if file_key not in self._file_contents: # Double-check after acquiring the lock
159
+ try:
160
+ if sha is not None:
161
+ file_content = self.repo.get_contents(file_path, ref=sha)
162
+ else:
163
+ file_content = self.repo.get_contents(file_path)
164
+
165
+ # Improved encoding handling
166
+ if file_content.encoding == 'base64':
167
+ decoded_content = base64.b64decode(file_content.content).decode('utf-8')
168
+ else:
169
+ decoded_content = file_content.decoded_content.decode('utf-8')
170
+
171
+ self._file_contents[file_key] = decoded_content
172
+ except GithubException as e:
173
+ logger.exception(
174
+ f"Error getting file content for {file_key} in repository {self.full_name}:")
175
+ return None
176
+ except UnicodeDecodeError as e:
177
+ logger.exception(
178
+ f"Error decoding file content for {file_key} in repository {self.full_name}:")
179
+ return None
180
+
181
+ self.update_last_read_time()
182
+ return self._file_contents.get(file_key)
183
+
184
+ def get_issue_content(self, number, issue=None) -> str:
185
+ """
186
+ Retrieves the content of a issue using a singleton design pattern.
187
+ """
188
+ if number not in self._issues: # Check if issue has already been fetched
189
+ with self._issue_lock: # Locking for write action
190
+ if number not in self._issues: # Check if issue has already been fetched after get lock
191
+ try:
192
+ if issue is None:
193
+ issue = self.repo.get_issue(number=number)
194
+ comments_amount = issue.comments
195
+ body_content = "This is a Github Issue related to repo \"" + (self.full_name or "") + "\". Repo description:" + (self.description or "") +\
196
+ "\n\nIssue Title: "+issue.title+"\nIssue state: "+issue.state + \
197
+ "\nIssue last updated at: "+str(issue.updated_at) + \
198
+ "\nTotal favour: " + \
199
+ str(issue.reactions['total_count']) + \
200
+ "\nComment amount: " + \
201
+ str(comments_amount) + \
202
+ "\nIssue body:\n" + \
203
+ re.sub(r'\n+\s*\n+', '\n',
204
+ issue.body.replace('\r', '\n').strip())
205
+ else:
206
+ comments_amount = issue['comments']
207
+ body_content = "This is a Github Issue related to repo \"" + (self.full_name or "") + "\". Repo description:" + (self.description or "") +\
208
+ "\n\nIssue title: "+issue['title']+"\nIssue state: "+issue['state'] + \
209
+ "\nIssue last updated at: "+str(issue['updated_at']) + \
210
+ "\nTotal favour: " + \
211
+ str(issue['reactions']['total_count']) + \
212
+ "\nComment amount: " + \
213
+ str(comments_amount) + \
214
+ "\nIssue body:\n" + \
215
+ re.sub(
216
+ r'\n+\s*\n+', '\n', issue['body'].replace('\r', '\n').strip())
217
+ if comments_amount > 0:
218
+ comments = self._github.get_issue_comments(
219
+ repo_full_name=self.full_name, issue_number=number)
220
+ comments_text_list = []
221
+ for index, comment in enumerate(comments, start=1):
222
+ cleaned_body = re.sub(
223
+ r'\n+\s*\n+', '\n', comment['body'].replace('\r', '\n').strip())
224
+ comment_text = (
225
+ f"Comment No.{index} - comment author type: {comment['user']['type']}; "
226
+ f"comment total favour: {comment['reactions']['total_count']}\n"
227
+ f"Comment body:\n{cleaned_body}\n"
228
+ )
229
+ comments_text_list.append(comment_text)
230
+ comments_text = "\n".join(comments_text_list)
231
+ issue_content = body_content + "\n\nIssue Comments:\n" + comments_text
232
+ else:
233
+ issue_content = body_content
234
+ self._issues[number] = issue_content.strip()
235
+ except GithubException as e:
236
+ logger.exception(
237
+ f"Error getting issue content for {number} in repository {self.full_name}:")
238
+ return None
239
+ self.update_last_read_time()
240
+ return self._issues[number]
241
+
242
+ def extract_related_issues(self, pr_data: Dict[str, Any]) -> List[int]:
243
+ """
244
+ Extracts related issue numbers from the PR description and other fields.
245
+
246
+ :param pr_data: The pull request data dictionary.
247
+ :return: A list of related issue numbers.
248
+ """
249
+ patterns = [
250
+ rf'https://github\.com/{re.escape(self.full_name)}/issues/(\d+)',
251
+ r'(?:^|\s)#(\d+)',
252
+ r'(?:^|\s)(\d+)(?:\s|$)',
253
+ ]
254
+ issues = set()
255
+ # Convert PR data to JSON string for pattern matching
256
+ pr_description = json.dumps(pr_data, default=str)
257
+
258
+ for pattern in patterns:
259
+ matches = re.findall(pattern, pr_description)
260
+ issues.update(int(match) for match in matches)
261
+
262
+ return list(issues)
263
+
264
+ def get_issue_contents(self, issue_numbers: List[int], pr_number: int) -> List[Dict[str, Any]]:
265
+ """
266
+ Retrieves contents for a list of issue numbers, excluding the PR number.
267
+
268
+ :param issue_numbers: List of issue numbers.
269
+ :param pr_number: The PR number to exclude.
270
+ :return: A list of issue contents.
271
+ """
272
+ return [
273
+ {
274
+ "issue_number": issue_number,
275
+ "issue_content": self.get_issue_content(issue_number)
276
+ }
277
+ for issue_number in issue_numbers
278
+ if issue_number != pr_number
279
+ ]
280
+
281
+ def to_isoformat(self, dt: datetime) -> Optional[str]:
282
+ """
283
+ Converts a datetime object to ISO 8601 format with UTC timezone.
284
+
285
+ :param dt: The datetime object to convert.
286
+ :return: ISO 8601 formatted string or None if dt is None.
287
+ """
288
+ if dt:
289
+ return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
290
+ return None
291
+
292
+ def get_pr_content(self, number, pr=None, context_lines=10) -> Dict[str, Any]:
293
+ """
294
+ Retrieves and processes the content of a pull request.
295
+
296
+ :param number: The PR number.
297
+ :param pr: Optional PR object.
298
+ :param context_lines: Number of context lines for diffs.
299
+ :return: A dictionary containing detailed PR information.
300
+ """
301
+ if number not in self._prs: # Check if issue has already been fetched
302
+ with self._pr_lock: # Locking for write action
303
+ if number not in self._prs: # Check if issue has already been fetched after get lock
304
+ try:
305
+ logger.debug(f"Processing PR #{number}")
306
+ if pr is None:
307
+ pr = self.repo.get_pull(number)
308
+ pr_files = self._github.get_pr_files(self.full_name, pr.number)
309
+ pr_comments = self._github.get_pr_comments(self.full_name, pr.number)
310
+
311
+ # Prepare PR metadata with standardized time formats
312
+ pr_data = {
313
+ "pr_metadata": {
314
+ "number": pr.number,
315
+ "title": pr.title,
316
+ "description": pr.body,
317
+ "author": pr.user.login,
318
+ "author_association": pr.raw_data['author_association'],
319
+ "created_at": self.to_isoformat(pr.created_at),
320
+ "updated_at": self.to_isoformat(pr.updated_at),
321
+ "merged_at": self.to_isoformat(pr.merged_at),
322
+ "state": pr.state,
323
+ "base_branch": pr.base.ref,
324
+ "head_branch": pr.head.ref,
325
+ },
326
+ "related_issues": [],
327
+ "file_changes": [],
328
+ "ci_cd_results": [],
329
+ "interactions": []
330
+ }
331
+
332
+ # Fetch CI/CD results
333
+ try:
334
+ last_commit = pr.get_commits().reversed[0]
335
+ statuses = last_commit.get_statuses()
336
+ ci_cd_results = {
337
+ "state": None, # We'll set this later
338
+ "statuses": [],
339
+ "check_runs": []
340
+ }
341
+
342
+ # Process statuses
343
+ for status in statuses:
344
+ if ci_cd_results["state"] is None:
345
+ ci_cd_results["state"] = status.state
346
+ ci_cd_results["statuses"].append({
347
+ "context": status.context,
348
+ "state": status.state,
349
+ "description": status.description,
350
+ "target_url": status.target_url,
351
+ "created_at": self.to_isoformat(status.created_at),
352
+ "updated_at": self.to_isoformat(status.updated_at),
353
+ })
354
+
355
+ # Fetch check runs for detailed CI/CD outputs
356
+ check_runs = last_commit.get_check_runs()
357
+ for check_run in check_runs:
358
+ ci_cd_results["check_runs"].append({
359
+ "name": check_run.name,
360
+ "status": check_run.status,
361
+ "conclusion": check_run.conclusion,
362
+ "started_at": self.to_isoformat(check_run.started_at) if check_run.started_at else None,
363
+ "completed_at": self.to_isoformat(check_run.completed_at) if check_run.completed_at else None,
364
+ "details_url": check_run.html_url, # Changed from details_url to html_url
365
+ })
366
+
367
+ pr_data["ci_cd_results"] = ci_cd_results
368
+ except GithubException as e:
369
+ logger.exception(f"Error fetching CI/CD results for PR #{number}")
370
+ except IndexError as e:
371
+ logger.exception(f"No commits found for PR #{number}")
372
+
373
+ # Combine comments and reviews, and sort by creation time
374
+ pr_interactions = []
375
+
376
+ # Process PR comments
377
+ for comment in pr_comments:
378
+ created_at_dt = parser.isoparse(comment["created_at"])
379
+ formatted_created_at = self.to_isoformat(created_at_dt)
380
+ pr_interactions.append({
381
+ "type": "pr_comment",
382
+ "author": comment["user"]["login"],
383
+ "author_association": comment["author_association"],
384
+ "content": comment["body"],
385
+ "created_at": formatted_created_at,
386
+ "comment_id": comment["id"]
387
+ })
388
+
389
+ # Process reviews and inline comments
390
+ reviews = pr.get_reviews()
391
+ review_comments = pr.get_review_comments()
392
+ for review in reviews:
393
+ pr_review = {
394
+ "type": "review",
395
+ "author": review.user.login,
396
+ "author_association": review.raw_data['author_association'],
397
+ "content": review.body,
398
+ "state": review.state,
399
+ "created_at": self.to_isoformat(review.submitted_at),
400
+ "comment_id": review.id
401
+ }
402
+ # Process inline comments with threading
403
+ for comment in review_comments:
404
+ if comment.pull_request_review_id == review.id:
405
+ pr_review["type"] = "inline_comment"
406
+ pr_review["content"] = comment.body
407
+ pr_review["path"] = comment.path
408
+ pr_review["diff_hunk"] = comment.diff_hunk
409
+ pr_interactions.append(pr_review)
410
+
411
+ # Sort interactions by creation time
412
+ pr_interactions.sort(key=lambda x: parser.isoparse(x["created_at"]))
413
+ pr_data["interactions"] = pr_interactions
414
+
415
+ # Extract related issues from PR description and comments
416
+ related_issues = self.extract_related_issues(pr_data)
417
+ pr_data['related_issues'] = self.get_issue_contents(related_issues, pr.number)
418
+
419
+ # Process file changes
420
+ dependency_files = ['requirements.txt', 'Pipfile', 'Pipfile.lock', 'setup.py']
421
+ config_files = ['.env', 'settings.py', 'config.yaml', 'config.yml', 'config.json']
422
+ for file in pr_files:
423
+ file_path = file['filename']
424
+ change_type = file['status']
425
+ language = file.get('language', self.language)
426
+ additions = file['additions']
427
+ deletions = file['deletions']
428
+ changes = file['changes']
429
+
430
+ # Fetch file content for base and head versions
431
+ if change_type == 'removed':
432
+ base_content = self.get_file_content(file_path=file_path, sha=pr.base.sha)
433
+ head_content = None
434
+ elif change_type == 'added':
435
+ base_content = None
436
+ head_content = self.get_file_content(file_path=file_path, sha=pr.head.sha)
437
+ else:
438
+ base_content = self.get_file_content(file_path=file_path, sha=pr.base.sha)
439
+ head_content = self.get_file_content(file_path=file_path, sha=pr.head.sha)
440
+
441
+ # Generate custom diff with specified context lines
442
+ custom_diff = DiffGenerator.generate_custom_diff(base_content, head_content, context_lines)
443
+
444
+ # Categorize code changes
445
+ change_categories = CodeAnalyzer.categorize_change(custom_diff)
446
+
447
+ # Extract imports from head content
448
+ related_modules = CodeAnalyzer.extract_imports(head_content) if language == 'Python' and head_content else []
449
+
450
+ # Build file change entry
451
+ file_change = {
452
+ "file_path": file_path,
453
+ "change_type": change_type,
454
+ "diff": custom_diff,
455
+ "language": language,
456
+ "additions": additions,
457
+ "deletions": deletions,
458
+ "changes": changes,
459
+ "change_categories": change_categories,
460
+ "related_modules": related_modules
461
+ }
462
+
463
+ # Check for dependency changes
464
+ if file_path in dependency_files:
465
+ pr_data.setdefault("dependency_changes", []).append({
466
+ "file_path": file_path,
467
+ "content": head_content
468
+ })
469
+
470
+ # Check for configuration changes
471
+ if file_path in config_files:
472
+ pr_data.setdefault("config_changes", []).append({
473
+ "file_path": file_path,
474
+ "content": head_content
475
+ })
476
+
477
+ pr_data['file_changes'].append(file_change)
478
+ logger.debug(f"Processed file: {file_path}")
479
+
480
+ self._prs[number] = pr_data
481
+ logger.debug(f"Collected details for PR #{number}: {pr.title}")
482
+ except GithubException as e:
483
+ logger.exception(f"Error getting PR content for #{number} in repository {self.full_name}")
484
+ self.update_last_read_time()
485
+ return self._prs[number]
486
+
487
+ def __str__(self):
488
+ """
489
+ String representation of the Repository instance.
490
+
491
+ :return: A string describing the repository.
492
+ """
493
+ self.update_last_read_time()
494
+ return f"{self.full_name} - {self.description}"
495
+
496
+ def clear_cache(self):
497
+ """
498
+ Clears the cached data of the repository, including structure, file contents, and README content.
499
+ """
500
+ with self._structure_lock:
501
+ self._structure = None # Reset the repository structure cache
502
+ with self._file_contents_lock:
503
+ self._file_contents = {} # Reset the file contents cache
504
+ with self._issue_lock:
505
+ self._issues = {} # Reset the issue contents cache
506
+ with self._readme_lock:
507
+ self._readme = None # Reset the README content cache
508
+ with self._repo_lock:
509
+ self._repo = None # Reset the Repo cache
510
+ with self._pr_lock:
511
+ self._prs = {} # Reset the PR cache
512
+
513
+
514
+ class RepositoryPool:
515
+ _instance_lock = Lock()
516
+ _instance = None
517
+
518
+ def __new__(cls, *args, **kwargs):
519
+ if cls._instance is None: # First check (unlocked)
520
+ with cls._instance_lock: # Acquire lock
521
+ if cls._instance is None: # Second check (locked)
522
+ cls._instance = super(RepositoryPool, cls).__new__(cls)
523
+ return cls._instance
524
+
525
+ def __init__(self, github_instance, cleanup_interval=3600, max_idle_time=86400):
526
+ if not hasattr(self, 'initialized'):
527
+ with self._instance_lock: # Prevent re-initialization
528
+ if not hasattr(self, 'initialized'):
529
+ self.initialized = True
530
+ self._locks_registry = {} # A registry for repository-specific locks
531
+ self._registry_lock = Lock() # A lock to protect the locks registry
532
+ self._pool = {} # The repository pool
533
+ self.github_instance = github_instance
534
+ self.cleanup_interval = cleanup_interval # How often to run cleanup in seconds
535
+ self.max_idle_time = max_idle_time # Maximum idle time in seconds
536
+ self._cleanup_thread = Thread(
537
+ target=self._cleanup, daemon=True)
538
+ self._stop_event = Event()
539
+ self._cleanup_thread.start()
540
+
541
+ def _cleanup(self): # Internal method for cleaning up idle repository objects' cache content, not real delete repository objects
542
+ """Periodically checks and removes idle repository objects."""
543
+ while not self._stop_event.is_set():
544
+ with self._registry_lock:
545
+ current_time = datetime.now(timezone.utc)
546
+ for full_name in list(self._pool.keys()):
547
+ repo = self._pool[full_name]
548
+ if ((current_time - repo.last_read_time).total_seconds() > self.max_idle_time) or \
549
+ ((current_time - repo.creation_time).total_seconds() > 7 * self.max_idle_time and
550
+ (current_time - repo.last_read_time).total_seconds() > (self.max_idle_time // 4) + 1):
551
+ # release the lock due to object already created
552
+ del self._locks_registry[full_name]
553
+ # clear the cache content of repository object
554
+ self._pool[full_name].clear_cache()
555
+ time.sleep(self.cleanup_interval)
556
+
557
+ def stop_cleanup(self):
558
+ """Stops the cleanup thread."""
559
+ self._stop_event.set()
560
+
561
+ def _get_repo_lock(self, full_name):
562
+ """Retrieve or create a lock for a specific repository."""
563
+ with self._registry_lock:
564
+ if full_name not in self._locks_registry:
565
+ # Create a new lock for the repository creation
566
+ self._locks_registry[full_name] = Lock()
567
+ return self._locks_registry[full_name]
568
+
569
+ def get_repository(self, full_name, **kwargs) -> Repository:
570
+ """Retrieve a repository from the pool or create a new one if it doesn't exist."""
571
+ if full_name in self._pool:
572
+ return self._pool[full_name]
573
+ repo_lock = self._get_repo_lock(full_name)
574
+ with repo_lock:
575
+ if full_name not in self._pool:
576
+ self._pool[full_name] = Repository(
577
+ full_name, self.github_instance, **kwargs)
578
+ return self._pool[full_name]