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.
- {llama_github-0.1.1 → llama_github-0.1.2}/CHANGELOG.md +19 -0
- {llama_github-0.1.1/llama_github.egg-info → llama_github-0.1.2}/PKG-INFO +20 -1
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/data_retrieval/github_api.py +21 -0
- llama_github-0.1.2/llama_github/data_retrieval/github_entities.py +578 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_integration/github_auth_manager.py +79 -0
- llama_github-0.1.2/llama_github/utils.py +307 -0
- llama_github-0.1.2/llama_github/version.py +1 -0
- {llama_github-0.1.1 → llama_github-0.1.2/llama_github.egg-info}/PKG-INFO +20 -1
- {llama_github-0.1.1 → llama_github-0.1.2}/setup.cfg +1 -1
- llama_github-0.1.1/llama_github/data_retrieval/github_entities.py +0 -301
- llama_github-0.1.1/llama_github/utils.py +0 -98
- llama_github-0.1.1/llama_github/version.py +0 -1
- {llama_github-0.1.1 → llama_github-0.1.2}/LICENSE +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/MANIFEST.in +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/README.md +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/config.json +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/config/config.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/data_retrieval/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/feature_flags.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/features/insider_features.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_integration/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/github_rag.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/initial_load.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/llm_integration/llm_handler.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/logger.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/rag_processing/__init__.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github/rag_processing/rag_processor.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/SOURCES.txt +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/dependency_links.txt +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/requires.txt +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/llama_github.egg-info/top_level.txt +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/setup.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_data_retrieval.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_github_auth_manager.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_initial_load.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_llm_handler.py +0 -0
- {llama_github-0.1.1 → llama_github-0.1.2}/tests/test_logger.py +0 -0
- {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.
|
|
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]
|