llama-github 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,301 @@
1
+ # To do list:
2
+ # 1. Add issues to the Repository class.
3
+ # 2. Add a method to get issues for a repository.
4
+
5
+ from github import GithubException
6
+ from threading import Lock, Event, Thread
7
+ from datetime import datetime, timezone
8
+ import time
9
+ from llama_github.logger import logger
10
+ from llama_github.github_integration.github_auth_manager import ExtendedGithub
11
+ import re
12
+
13
+
14
+ class Repository:
15
+ def __init__(self, full_name, github_instance: ExtendedGithub, **kwargs):
16
+ """
17
+ Initializes a Repository instance with details and a GitHub instance for API calls.
18
+
19
+ :param id: The ID of the repository.
20
+ :param name: The name of the repository.
21
+ :param full_name: The full name of the repository (e.g., 'octocat/Hello-World').
22
+ :param description: The description of the repository.
23
+ :param html_url: The HTML URL of the repository.
24
+ :param stargazers_count: The count of stargazers of the repository.
25
+ :param watchers_count: The count of watchers of the repository.
26
+ :param language: The primary language of the repository.
27
+ :param forks_count: The count of forks of the repository.
28
+ :param github_instance: Authenticated instance of a Github client.
29
+ """
30
+
31
+ self._github = github_instance
32
+ self.full_name = full_name
33
+
34
+ self.id = kwargs.get("id")
35
+ self.name = kwargs.get("name")
36
+ self.description = kwargs.get("description")
37
+ self.html_url = kwargs.get("html_url")
38
+ self.stargazers_count = kwargs.get("stargazers_count")
39
+ self.language = kwargs.get("language")
40
+ self.default_branch = kwargs.get("default_branch")
41
+ self.updated_at = kwargs.get("updated_at")
42
+ self.watchers_count = None
43
+
44
+ self.creation_time = datetime.now(timezone.utc)
45
+ self.last_read_time = datetime.now(timezone.utc)
46
+
47
+ self._repo = None
48
+
49
+ # Locking for write action
50
+ self._structure_lock = Lock()
51
+ self._file_contents_lock = Lock()
52
+ self._issue_lock = Lock()
53
+ self._readme_lock = Lock()
54
+ self._repo_lock = Lock()
55
+
56
+ if (self.id is None) or \
57
+ (self.name is None) or \
58
+ (self.full_name is None) or \
59
+ (self.html_url is None) or \
60
+ (self.stargazers_count is None) or \
61
+ (self.default_branch is None) or \
62
+ (self.updated_at is None):
63
+ self.get_repo()
64
+
65
+ self._structure = None # Singleton pattern for repository structure
66
+ self._file_contents = {} # Singleton pattern for file contents
67
+ self._issues = {} # Singleton pattern for file contents
68
+ self._readme = None # Singleton pattern for README content
69
+
70
+ def update_last_read_time(self):
71
+ self.last_read_time = datetime.now(timezone.utc)
72
+
73
+ def get_readme(self) -> str:
74
+ """
75
+ Retrieves the README content of the repository using a singleton design pattern.
76
+ """
77
+ if self._readme is None: # Check if README content has already been fetched
78
+ with self._readme_lock: # Locking for write action
79
+ if self._readme is None: # Check if README content has already been fetched after get lock
80
+ try:
81
+ readme = self.repo.get_readme()
82
+ self._readme = readme.decoded_content.decode("utf-8")
83
+ except GithubException as e:
84
+ logger.exception(
85
+ f"Error getting README for repository {self.full_name}:")
86
+ self._readme = None
87
+ self.update_last_read_time()
88
+ return self._readme
89
+
90
+ @property
91
+ def repo(self):
92
+ return self.get_repo()
93
+
94
+ def get_repo(self):
95
+ """
96
+ Retrieves the Github Repo object of the repository using a singleton design pattern.
97
+ """
98
+ if self._repo is None: # Check if repo object has already been fetched
99
+ with self._repo_lock: # Locking for write action
100
+ if self._repo is None:
101
+ try:
102
+ self._repo = self._github.get_repo(self.full_name)
103
+ self.id = self._repo.id
104
+ self.name = self._repo.name
105
+ self.description = self._repo.description
106
+ self.html_url = self._repo.html_url
107
+ self.stargazers_count = self._repo.stargazers_count
108
+ self.watchers_count = self._repo.subscribers_count
109
+ self.language = self._repo.language
110
+ self.default_branch = self._repo.default_branch
111
+ self.updated_at = self._repo.updated_at
112
+ except GithubException as e:
113
+ logger.exception(
114
+ f"Error retrieving repository '{self.full_name}':")
115
+ return None
116
+ self.update_last_read_time()
117
+ return self._repo
118
+
119
+ def get_structure(self, path="/") -> dict:
120
+ """
121
+ Retrieves the structure of the repository using a singleton design pattern.
122
+ """
123
+ if self._structure is None: # Check if structure has already been fetched
124
+ with self._structure_lock: # Locking for write action
125
+ if self._structure is None: # Check if structure has already been fetched after get lock
126
+ try:
127
+ self._structure = self._github.get_repo_structure(
128
+ self.full_name, self.default_branch)
129
+ except GithubException as e:
130
+ logger.exception(
131
+ f"Error getting structure for repository {self.full_name}:")
132
+ self._structure = None
133
+ self.update_last_read_time()
134
+ return self._structure
135
+
136
+ def get_file_content(self, file_path) -> str:
137
+ """
138
+ Retrieves the content of a file using a singleton design pattern.
139
+ """
140
+ if file_path not in self._file_contents: # Check if file content has already been fetched
141
+ with self._file_contents_lock: # Locking for write action
142
+ if file_path not in self._file_contents: # Check if file content has already been fetched after get lock
143
+ try:
144
+ file_content = self.repo.get_contents(file_path)
145
+ self._file_contents[file_path] = file_content.decoded_content.decode(
146
+ "utf-8")
147
+ except GithubException as e:
148
+ logger.exception(
149
+ f"Error getting file content for {file_path} in repository {self.full_name}:")
150
+ return None
151
+ self.update_last_read_time()
152
+ return self._file_contents[file_path]
153
+
154
+ def get_issue_content(self, number, issue=None) -> str:
155
+ """
156
+ Retrieves the content of a issue using a singleton design pattern.
157
+ """
158
+ if number not in self._issues: # Check if issue has already been fetched
159
+ with self._issue_lock: # Locking for write action
160
+ if number not in self._issues: # Check if issue has already been fetched after get lock
161
+ try:
162
+ if issue is None:
163
+ issue = self.repo.get_issue(number=number)
164
+ comments_amount = issue.comments
165
+ body_content = "This is a Github Issue related to repo \"" + (self.full_name or "") + "\". Repo description:" + (self.description or "") +\
166
+ "\n\nIssue Title: "+issue.title+"\nIssue state: "+issue.state + \
167
+ "\nIssue last updated at: "+str(issue.updated_at) + \
168
+ "\nTotal favour: " + \
169
+ str(issue.reactions['total_count']) + \
170
+ "\nComment amount: " + \
171
+ str(comments_amount) + \
172
+ "\nIssue body:\n" + \
173
+ re.sub(r'\n+\s*\n+', '\n',
174
+ issue.body.replace('\r', '\n').strip())
175
+ else:
176
+ comments_amount = issue['comments']
177
+ body_content = "This is a Github Issue related to repo \"" + (self.full_name or "") + "\". Repo description:" + (self.description or "") +\
178
+ "\n\nIssue title: "+issue['title']+"\nIssue state: "+issue['state'] + \
179
+ "\nIssue last updated at: "+str(issue['updated_at']) + \
180
+ "\nTotal favour: " + \
181
+ str(issue['reactions']['total_count']) + \
182
+ "\nComment amount: " + \
183
+ str(comments_amount) + \
184
+ "\nIssue body:\n" + \
185
+ re.sub(
186
+ r'\n+\s*\n+', '\n', issue['body'].replace('\r', '\n').strip())
187
+ if comments_amount > 0:
188
+ comments = self._github.get_issue_comments(
189
+ repo_full_name=self.full_name, issue_number=number)
190
+ comments_text_list = []
191
+ for index, comment in enumerate(comments, start=1):
192
+ cleaned_body = re.sub(
193
+ r'\n+\s*\n+', '\n', comment['body'].replace('\r', '\n').strip())
194
+ comment_text = (
195
+ f"Comment No.{index} - comment author type: {comment['user']['type']}; "
196
+ f"comment total favour: {comment['reactions']['total_count']}\n"
197
+ f"Comment body:\n{cleaned_body}\n"
198
+ )
199
+ comments_text_list.append(comment_text)
200
+ comments_text = "\n".join(comments_text_list)
201
+ issue_content = body_content + "\n\nIssue Comments:\n" + comments_text
202
+ else:
203
+ issue_content = body_content
204
+ self._issues[number] = issue_content.strip()
205
+ except GithubException as e:
206
+ logger.exception(
207
+ f"Error getting issue content for {number} in repository {self.full_name}:")
208
+ return None
209
+ self.update_last_read_time()
210
+ return self._issues[number]
211
+
212
+ def __str__(self):
213
+ """
214
+ String representation of the Repository instance.
215
+
216
+ :return: A string describing the repository.
217
+ """
218
+ self.update_last_read_time()
219
+ return f"{self.full_name} - {self.description}"
220
+
221
+ def clear_cache(self):
222
+ """
223
+ Clears the cached data of the repository, including structure, file contents, and README content.
224
+ """
225
+ with self._structure_lock:
226
+ self._structure = None # Reset the repository structure cache
227
+ with self._file_contents_lock:
228
+ self._file_contents = {} # Reset the file contents cache
229
+ with self._issue_lock:
230
+ self._issues = {} # Reset the issue contents cache
231
+ with self._readme_lock:
232
+ self._readme = None # Reset the README content cache
233
+ with self._repo_lock:
234
+ self._repo = None # Reset the Repo cache
235
+
236
+
237
+ class RepositoryPool:
238
+ _instance_lock = Lock()
239
+ _instance = None
240
+
241
+ def __new__(cls, *args, **kwargs):
242
+ if cls._instance is None: # First check (unlocked)
243
+ with cls._instance_lock: # Acquire lock
244
+ if cls._instance is None: # Second check (locked)
245
+ cls._instance = super(RepositoryPool, cls).__new__(cls)
246
+ return cls._instance
247
+
248
+ def __init__(self, github_instance, cleanup_interval=3600, max_idle_time=86400):
249
+ if not hasattr(self, 'initialized'):
250
+ with self._instance_lock: # Prevent re-initialization
251
+ if not hasattr(self, 'initialized'):
252
+ self.initialized = True
253
+ self._locks_registry = {} # A registry for repository-specific locks
254
+ self._registry_lock = Lock() # A lock to protect the locks registry
255
+ self._pool = {} # The repository pool
256
+ self.github_instance = github_instance
257
+ self.cleanup_interval = cleanup_interval # How often to run cleanup in seconds
258
+ self.max_idle_time = max_idle_time # Maximum idle time in seconds
259
+ self._cleanup_thread = Thread(
260
+ target=self._cleanup, daemon=True)
261
+ self._stop_event = Event()
262
+ self._cleanup_thread.start()
263
+
264
+ def _cleanup(self): # Internal method for cleaning up idle repository objects' cache content, not real delete repository objects
265
+ """Periodically checks and removes idle repository objects."""
266
+ while not self._stop_event.is_set():
267
+ with self._registry_lock:
268
+ current_time = datetime.now(timezone.utc)
269
+ for full_name in list(self._pool.keys()):
270
+ repo = self._pool[full_name]
271
+ if ((current_time - repo.last_read_time).total_seconds() > self.max_idle_time) or \
272
+ ((current_time - repo.creation_time).total_seconds() > 7 * self.max_idle_time and
273
+ (current_time - repo.last_read_time).total_seconds() > (self.max_idle_time // 4) + 1):
274
+ # release the lock due to object already created
275
+ del self._locks_registry[full_name]
276
+ # clear the cache content of repository object
277
+ self._pool[full_name].clear_cache()
278
+ time.sleep(self.cleanup_interval)
279
+
280
+ def stop_cleanup(self):
281
+ """Stops the cleanup thread."""
282
+ self._stop_event.set()
283
+
284
+ def _get_repo_lock(self, full_name):
285
+ """Retrieve or create a lock for a specific repository."""
286
+ with self._registry_lock:
287
+ if full_name not in self._locks_registry:
288
+ # Create a new lock for the repository creation
289
+ self._locks_registry[full_name] = Lock()
290
+ return self._locks_registry[full_name]
291
+
292
+ def get_repository(self, full_name, **kwargs) -> Repository:
293
+ """Retrieve a repository from the pool or create a new one if it doesn't exist."""
294
+ if full_name in self._pool:
295
+ return self._pool[full_name]
296
+ repo_lock = self._get_repo_lock(full_name)
297
+ with repo_lock:
298
+ if full_name not in self._pool:
299
+ self._pool[full_name] = Repository(
300
+ full_name, self.github_instance, **kwargs)
301
+ return self._pool[full_name]
File without changes
File without changes
File without changes
@@ -0,0 +1,7 @@
1
+ # Import the AuthManager for easy access
2
+ from .github_auth_manager import GitHubAuthManager
3
+
4
+ # Define what is available for import
5
+ __all__ = ["GitHubAuthManager"]
6
+
7
+ # Any initialization code specific to GitHub integration can go here
@@ -0,0 +1,266 @@
1
+ # To do list:
2
+ # 1. add the mechanism for installation_access_token and github_instance refreshment in authenticate_with_app model
3
+ # 2. add re-try mechanism for the API calls
4
+ # 3. add the mechanism for the rate limit handling
5
+ # 4. add the mechanism for the error handling
6
+ # 5. add the mechanism for the logging
7
+ # 6. add search issues functionality
8
+ # 7. add search discussions functionality through Github GraphQL API
9
+
10
+ from github import Github, GithubIntegration
11
+ import requests
12
+ from requests.adapters import HTTPAdapter
13
+ from requests.exceptions import HTTPError, RequestException
14
+ from urllib3.util.retry import Retry
15
+ from llama_github.logger import logger
16
+
17
+
18
+ class GitHubAuthManager:
19
+ def __init__(self):
20
+ self.github_instance = None
21
+ self.access_token = None
22
+ self.app_id = None
23
+ self.private_key = None
24
+ self.installation_id = None
25
+
26
+ def authenticate_with_token(self, access_token):
27
+ """
28
+ Authenticate using a personal access token or an OAuth token.
29
+ Suitable for individual developers and applications using OAuth for authorization.
30
+ """
31
+ self.access_token = access_token
32
+ self.github_instance = ExtendedGithub(login_or_token=access_token)
33
+ return self.github_instance
34
+
35
+ def authenticate_with_app(self, app_id, private_key, installation_id):
36
+ """
37
+ Authenticate using a GitHub App.
38
+ Suitable for integrations in organizational or enterprise environments.
39
+ """
40
+ self.app_id = app_id
41
+ self.private_key = private_key
42
+ self.installation_id = installation_id
43
+ integration = GithubIntegration(app_id, private_key)
44
+ installation_access_token = integration.get_access_token(
45
+ installation_id).token
46
+ self.access_token = installation_access_token
47
+ self.github_instance = ExtendedGithub(
48
+ login_or_token=installation_access_token)
49
+ return self.github_instance
50
+
51
+ def close_connection(self):
52
+ """
53
+ Close the connection to GitHub to free up resources.
54
+ """
55
+ if self.github_instance:
56
+ self.github_instance = None
57
+
58
+ # Extended Github Class for powerful API calls - e.g. recursive call to get repo structure
59
+
60
+
61
+ class ExtendedGithub(Github):
62
+ def __init__(self, login_or_token):
63
+ self.access_token = login_or_token
64
+ super().__init__(login_or_token=login_or_token)
65
+
66
+ def get_repo_structure(self, repo_full_name, branch='main') -> dict:
67
+ """
68
+ Get the structure of a repository (files and directories) recursively.
69
+ """
70
+ owner, repo_name = repo_full_name.split('/')
71
+ headers = {'Authorization': f'token {self.access_token}'}
72
+
73
+ # Function to convert the flat list to a hierarchical structure
74
+ def list_to_tree(items):
75
+ """
76
+ Convert the flat list to a hierarchical structure with full paths.
77
+ Include size metadata for files and remove 'type' attributes.
78
+ """
79
+ tree = {}
80
+ for item in items:
81
+ path_parts = item['path'].split('/')
82
+ current_level = tree
83
+ for part in path_parts[:-1]:
84
+ # Ensure 'children' dictionary exists for directories without explicitly adding 'type'
85
+ current_level = current_level.setdefault(
86
+ part, {'children': {}})
87
+ # Ensure we don't inadvertently create a 'type' key for directories
88
+ current_level = current_level.get('children')
89
+
90
+ # For the last part of the path, decide if it's a file or directory and add appropriate information
91
+ if item['type'] == 'blob': # It's a file
92
+ current_level[path_parts[-1]] = {
93
+ 'path': item['path'], # Include full path
94
+ # Include size if available
95
+ 'size': item.get('size', 0)
96
+ }
97
+ else: # It's a directory
98
+ # Initialize the directory if not already present, without adding 'type'
99
+ if path_parts[-1] not in current_level:
100
+ current_level[path_parts[-1]] = {'children': {}}
101
+ return tree
102
+
103
+ # Directly use the Trees API to get the full directory structure of the "main" branch
104
+ tree_url = f'https://api.github.com/repos/{owner}/{repo_name}/git/trees/{branch}?recursive=1'
105
+ tree_response = requests.get(tree_url, headers=headers)
106
+
107
+ # Check if the request was successful
108
+ if tree_response.status_code == 200:
109
+ tree_data = tree_response.json()
110
+ # Convert the flat list of items to a hierarchical tree structure
111
+ repo_structure = list_to_tree(tree_data['tree'])
112
+ return repo_structure
113
+ else:
114
+ print(
115
+ f"Error fetching tree structure: {tree_response.status_code}")
116
+ print("Details:", tree_response.json())
117
+
118
+ def search_code(self, query: str, per_page: int = 30) -> dict:
119
+ """
120
+ Search for code on GitHub using the GitHub API.
121
+
122
+ Parameters:
123
+ query (str): The search query.
124
+ per_page (int): The number of results per page.
125
+
126
+ Returns:
127
+ dict: The search result in dict format.
128
+ """
129
+ url = 'https://api.github.com/search/code'
130
+ headers = {
131
+ 'Accept': 'application/vnd.github.v3+json',
132
+ 'Authorization': f'token {self.access_token}'
133
+ }
134
+ params = {
135
+ 'q': query,
136
+ 'per_page': per_page
137
+ }
138
+
139
+ # Retry strategy
140
+ retry_strategy = Retry(
141
+ total=3, # Total number of retries
142
+ # Retry on these HTTP status codes
143
+ status_forcelist=[429, 500, 502, 503, 504],
144
+ # Retry on these HTTP methods
145
+ allowed_methods=["HEAD", "GET", "OPTIONS"],
146
+ backoff_factor=1 # Exponential backoff factor
147
+ )
148
+ adapter = HTTPAdapter(max_retries=retry_strategy)
149
+ http = requests.Session()
150
+ http.mount("https://", adapter)
151
+
152
+ try:
153
+ response = http.get(url, headers=headers, params=params)
154
+ response.raise_for_status() # Raise HTTPError for bad responses
155
+ return response.json().get('items', [])
156
+ except HTTPError as http_err:
157
+ logger.error(f"HTTP error occurred: {http_err}")
158
+ except RequestException as req_err:
159
+ logger.error(f"Request error occurred: {req_err}")
160
+ except Exception as err:
161
+ logger.error(f"An error occurred: {err}")
162
+
163
+ def search_issues(self, query: str, per_page: int = 30) -> dict:
164
+ """
165
+ Search for code on GitHub using the GitHub API.
166
+
167
+ Parameters:
168
+ query (str): The search query.
169
+ per_page (int): The number of results per page.
170
+
171
+ Returns:
172
+ dict: The search result in dict format.
173
+ """
174
+ url = 'https://api.github.com/search/issues'
175
+ headers = {
176
+ 'Accept': 'application/vnd.github.v3+json',
177
+ 'Authorization': f'token {self.access_token}'
178
+ }
179
+ params = {
180
+ 'q': query,
181
+ 'per_page': per_page
182
+ }
183
+
184
+ # Retry strategy
185
+ retry_strategy = Retry(
186
+ total=3, # Total number of retries
187
+ # Retry on these HTTP status codes
188
+ status_forcelist=[429, 500, 502, 503, 504],
189
+ # Retry on these HTTP methods
190
+ allowed_methods=["HEAD", "GET", "OPTIONS"],
191
+ backoff_factor=1 # Exponential backoff factor
192
+ )
193
+ adapter = HTTPAdapter(max_retries=retry_strategy)
194
+ http = requests.Session()
195
+ http.mount("https://", adapter)
196
+
197
+ try:
198
+ response = http.get(url, headers=headers, params=params)
199
+ response.raise_for_status() # Raise HTTPError for bad responses
200
+ return response.json().get('items', [])
201
+ except HTTPError as http_err:
202
+ logger.error(f"HTTP error occurred: {http_err}")
203
+ except RequestException as req_err:
204
+ logger.error(f"Request error occurred: {req_err}")
205
+ except Exception as err:
206
+ logger.error(f"An error occurred: {err}")
207
+
208
+ def get_issue_comments(self, repo_full_name: str, issue_number: int) -> dict:
209
+ """
210
+ Get comments of an issue on GitHub using the GitHub API.
211
+
212
+ Parameters:
213
+ repo_full_name (str): The full name of the repository (e.g., 'octocat/Hello-World').
214
+ issue_number (int): The issue number.
215
+
216
+ Returns:
217
+ dict: The comments of the issue in dict format.
218
+ """
219
+ url = f'https://api.github.com/repos/{repo_full_name}/issues/{issue_number}/comments'
220
+ headers = {
221
+ 'Accept': 'application/vnd.github.v3+json',
222
+ 'Authorization': f'token {self.access_token}'
223
+ }
224
+ # Retry strategy
225
+ retry_strategy = Retry(
226
+ total=3, # Total number of retries
227
+ # Retry on these HTTP status codes
228
+ status_forcelist=[429, 500, 502, 503, 504],
229
+ # Retry on these HTTP methods
230
+ allowed_methods=["HEAD", "GET", "OPTIONS"],
231
+ backoff_factor=1 # Exponential backoff factor
232
+ )
233
+ adapter = HTTPAdapter(max_retries=retry_strategy)
234
+ http = requests.Session()
235
+ http.mount("https://", adapter)
236
+
237
+ try:
238
+ response = http.get(url, headers=headers)
239
+ response.raise_for_status() # Raise HTTPError for bad responses
240
+ return response.json()
241
+ except HTTPError as http_err:
242
+ logger.error(f"HTTP error occurred: {http_err}")
243
+ except RequestException as req_err:
244
+ logger.error(f"Request error occurred: {req_err}")
245
+ except Exception as err:
246
+ logger.error(f"An error occurred: {err}")
247
+
248
+
249
+ # Example usage:
250
+ if __name__ == "__main__":
251
+ auth_manager = GitHubAuthManager()
252
+
253
+ # For developers using a personal access token or an OAuth token
254
+ github_instance = auth_manager.authenticate_with_token(
255
+ "your_personal_access_token_or_oauth_token_here")
256
+
257
+ # For organizational or enterprise environments using GitHub App
258
+ # github_instance = auth_manager.authenticate_with_app("app_id", "private_key", "installation_id")
259
+
260
+ # Example action: List all repositories for the authenticated user
261
+ if github_instance:
262
+ for repo in github_instance.get_user().get_repos():
263
+ print(repo.name)
264
+
265
+ # Close the connection when done
266
+ auth_manager.close_connection()