git-recap 0.1.5__py3-none-any.whl → 0.1.6__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.
@@ -12,7 +12,7 @@ class GitHubFetcher(BaseFetcher):
12
12
  """
13
13
  Fetcher implementation for GitHub repositories.
14
14
 
15
- Supports fetching commits, pull requests, issues, and releases.
15
+ Supports fetching commits, pull requests, issues, releases, and authors.
16
16
  """
17
17
 
18
18
  def __init__(self, pat: str, start_date=None, end_date=None, repo_filter=None, authors=None):
@@ -92,9 +92,7 @@ class GitHubFetcher(BaseFetcher):
92
92
 
93
93
  def fetch_pull_requests(self) -> List[Dict[str, Any]]:
94
94
  entries = []
95
- # Maintain a local set to skip duplicate commits already captured in a PR.
96
95
  processed_pr_commits = set()
97
- # Retrieve repos where you're owner, a collaborator, or an organization member.
98
96
  for repo in self.repos:
99
97
  if self.repo_filter and repo.name not in self.repo_filter:
100
98
  continue
@@ -102,11 +100,10 @@ class GitHubFetcher(BaseFetcher):
102
100
  for i, pr in enumerate(pulls, start=1):
103
101
  if pr.user.login not in self.authors:
104
102
  continue
105
- pr_date = pr.updated_at # alternatively, use pr.created_at
103
+ pr_date = pr.updated_at
106
104
  if not self._filter_by_date(pr_date):
107
105
  continue
108
106
 
109
- # Add the pull request itself.
110
107
  pr_entry = {
111
108
  "type": "pull_request",
112
109
  "repo": repo.name,
@@ -116,7 +113,6 @@ class GitHubFetcher(BaseFetcher):
116
113
  }
117
114
  entries.append(pr_entry)
118
115
 
119
- # Now, add commits associated with this pull request.
120
116
  pr_commits = pr.get_commits()
121
117
  for pr_commit in pr_commits:
122
118
  commit_date = pr_commit.commit.author.date
@@ -178,7 +174,6 @@ class GitHubFetcher(BaseFetcher):
178
174
  continue
179
175
  try:
180
176
  for rel in repo.get_releases():
181
- # Compose asset list
182
177
  assets = []
183
178
  for asset in rel.get_assets():
184
179
  assets.append({
@@ -203,7 +198,6 @@ class GitHubFetcher(BaseFetcher):
203
198
  }
204
199
  releases.append(release_entry)
205
200
  except Exception:
206
- # If fetching releases fails for a repo, skip it (could be permissions or no releases)
207
201
  continue
208
202
  return releases
209
203
 
@@ -267,7 +261,6 @@ class GitHubFetcher(BaseFetcher):
267
261
  continue
268
262
  logger.debug(f"Processing repository: {repo.name}")
269
263
  repo_branches = [branch.name for branch in repo.get_branches()]
270
- # Get existing open PRs from source branch
271
264
  try:
272
265
  open_prs = repo.get_pulls(state='open', head=source_branch)
273
266
  except GithubException as e:
@@ -284,7 +277,6 @@ class GitHubFetcher(BaseFetcher):
284
277
  if branch_name in existing_pr_targets:
285
278
  logger.debug(f"Excluding branch with existing PR: {branch_name}")
286
279
  continue
287
- # Optionally check if source is ahead of target (performance cost)
288
280
  valid_targets.append(branch_name)
289
281
  logger.debug(f"Valid target branch: {branch_name}")
290
282
  logger.debug(f"Found {len(valid_targets)} valid target branches")
@@ -405,4 +397,80 @@ class GitHubFetcher(BaseFetcher):
405
397
  raise
406
398
  except Exception as e:
407
399
  logger.error(f"Unexpected error while creating pull request: {str(e)}")
408
- raise Exception(f"Failed to create pull request: {str(e)}")
400
+ raise Exception(f"Failed to create pull request: {str(e)}")
401
+
402
+ def get_authors(self, repo_names: List[str]) -> List[Dict[str, str]]:
403
+ """
404
+ Retrieve unique authors from specified GitHub repositories.
405
+
406
+ Args:
407
+ repo_names: List of repository names (format: "owner/repo").
408
+ Empty list fetches from all accessible repositories.
409
+
410
+ Returns:
411
+ List of unique author dictionaries with name and email.
412
+ """
413
+ authors_set = set()
414
+
415
+ try:
416
+ if not repo_names:
417
+ repos = self.github.get_user().get_repos()
418
+ repo_names = [repo.full_name for repo in repos]
419
+
420
+ for repo_name in repo_names:
421
+ try:
422
+ repo = self.github.get_repo(repo_name)
423
+
424
+ commits = repo.get_commits()
425
+
426
+ for commit in commits:
427
+ if commit.commit.author:
428
+ author_name = commit.commit.author.name or "Unknown"
429
+ author_email = commit.commit.author.email or "unknown@example.com"
430
+ authors_set.add((author_name, author_email))
431
+
432
+ if commit.commit.committer:
433
+ committer_name = commit.commit.committer.name or "Unknown"
434
+ committer_email = commit.commit.committer.email or "unknown@example.com"
435
+ authors_set.add((committer_name, committer_email))
436
+
437
+ except GithubException as e:
438
+ print(f"Error fetching authors from {repo_name}: {e}")
439
+ continue
440
+
441
+ authors_list = [
442
+ {"name": name, "email": email}
443
+ for name, email in sorted(authors_set)
444
+ ]
445
+
446
+ return authors_list
447
+
448
+ except Exception as e:
449
+ print(f"Error in get_authors: {e}")
450
+ return []
451
+
452
+ def get_current_author(self) -> Optional[Dict[str, str]]:
453
+ """
454
+ Retrieve the current authenticated user's information.
455
+
456
+ Returns the authenticated GitHub user's name and email if available.
457
+
458
+ Returns:
459
+ Optional[Dict[str, str]]: Dictionary with 'name' and 'email' keys,
460
+ or None if user information is unavailable.
461
+ """
462
+ try:
463
+ if not self.user:
464
+ logger.warning("No authenticated user available")
465
+ return None
466
+
467
+ user_name = self.user.name or self.user.login or "Unknown"
468
+ user_email = self.user.email or f"{self.user.login}@users.noreply.github.com"
469
+
470
+ return {
471
+ "name": user_name,
472
+ "email": user_email
473
+ }
474
+ except Exception as e:
475
+ logger.error(f"Error retrieving current author: {str(e)}")
476
+ return None
@@ -7,7 +7,7 @@ class GitLabFetcher(BaseFetcher):
7
7
  """
8
8
  Fetcher implementation for GitLab repositories.
9
9
 
10
- Supports fetching commits, merge requests (pull requests), and issues.
10
+ Supports fetching commits, merge requests (pull requests), issues, and authors.
11
11
  Release fetching is not supported and will raise NotImplementedError.
12
12
  """
13
13
 
@@ -246,4 +246,69 @@ class GitLabFetcher(BaseFetcher):
246
246
  Raises:
247
247
  NotImplementedError: Always, since PR creation is not yet implemented for GitLabFetcher.
248
248
  """
249
- raise NotImplementedError("Pull request (merge request) creation is not yet implemented for GitLab (GitLabFetcher).")
249
+ raise NotImplementedError("Pull request (merge request) creation is not yet implemented for GitLab (GitLabFetcher).")
250
+
251
+ def get_authors(self, repo_names: List[str]) -> List[Dict[str, str]]:
252
+ """
253
+ Retrieve unique authors from specified GitLab projects.
254
+
255
+ Args:
256
+ repo_names: List of project names/IDs.
257
+ Empty list fetches from all accessible projects.
258
+
259
+ Returns:
260
+ List of unique author dictionaries with name and email.
261
+ """
262
+ authors_set = set()
263
+
264
+ try:
265
+ # If no specific projects provided, get all accessible projects
266
+ if not repo_names:
267
+ projects = self.gl.projects.list(membership=True, all=True)
268
+ repo_names = [project.path_with_namespace for project in projects]
269
+
270
+ for repo_name in repo_names:
271
+ try:
272
+ project = self.gl.projects.get(repo_name)
273
+
274
+ # Fetch commits
275
+ commits = project.commits.list(all=True)
276
+
277
+ for commit in commits:
278
+ author_name = commit.author_name or "Unknown"
279
+ author_email = commit.author_email or "unknown@example.com"
280
+ authors_set.add((author_name, author_email))
281
+
282
+ # Also add committer if available
283
+ if hasattr(commit, 'committer_name') and commit.committer_name:
284
+ committer_name = commit.committer_name
285
+ committer_email = commit.committer_email or "unknown@example.com"
286
+ authors_set.add((committer_name, committer_email))
287
+
288
+ except gitlab.exceptions.GitlabGetError as e:
289
+ print(f"Error fetching authors from {repo_name}: {e}")
290
+ continue
291
+
292
+ # Convert set to list of dictionaries
293
+ authors_list = [
294
+ {"name": name, "email": email}
295
+ for name, email in sorted(authors_set)
296
+ ]
297
+
298
+ return authors_list
299
+
300
+ except Exception as e:
301
+ print(f"Error in get_authors: {e}")
302
+ return []
303
+
304
+ def get_current_author(self) -> Optional[Dict[str, str]]:
305
+ """
306
+ Retrieve the current authenticated user's information.
307
+
308
+ For GitLab, default author functionality is not currently implemented,
309
+ so this method returns None.
310
+
311
+ Returns:
312
+ None: GitLab fetcher does not support default author retrieval.
313
+ """
314
+ return None
@@ -0,0 +1,390 @@
1
+ import os
2
+ import subprocess
3
+ from typing import List, Dict, Any, Optional
4
+ from datetime import datetime
5
+ from git_recap.providers.base_fetcher import BaseFetcher
6
+
7
+
8
+ class LocalFetcher(BaseFetcher):
9
+ """
10
+ Fetcher implementation for local Git repositories.
11
+
12
+ Works directly on a local git repository path without cloning.
13
+ Supports fetching commits, authors, and branch information.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ repo_path: str,
19
+ authors: Optional[List[str]] = None,
20
+ start_date: Optional[datetime] = None,
21
+ end_date: Optional[datetime] = None,
22
+ repo_filter: Optional[List[str]] = None,
23
+ validate_repo: bool = True
24
+ ):
25
+ """
26
+ Initialize the LocalFetcher.
27
+
28
+ Args:
29
+ repo_path: Path to the local git repository
30
+ authors: List of author names to filter by (optional)
31
+ start_date: Start date for filtering commits (optional)
32
+ end_date: End date for filtering commits (optional)
33
+ repo_filter: List of repository names to filter (optional)
34
+ validate_repo: Whether to validate the repository path (default: True)
35
+ """
36
+ super().__init__(pat=None, start_date=start_date, end_date=end_date, repo_filter=repo_filter, authors=authors)
37
+ self.repo_path = repo_path
38
+ if validate_repo:
39
+ self._validate_repo()
40
+
41
+ def _validate_repo(self) -> None:
42
+ """
43
+ Validate that the provided path is a valid git repository.
44
+
45
+ Raises:
46
+ ValueError: If the path is not a valid git repository.
47
+ """
48
+ if not os.path.exists(self.repo_path):
49
+ raise ValueError(f"Path does not exist: {self.repo_path}")
50
+
51
+ if not os.path.isdir(self.repo_path):
52
+ raise ValueError(f"Path is not a directory: {self.repo_path}")
53
+
54
+ git_dir = os.path.join(self.repo_path, '.git')
55
+ if not os.path.exists(git_dir):
56
+ raise ValueError(f"Not a git repository: {self.repo_path}")
57
+
58
+ # Verify it's a valid git repo by running a simple command
59
+ try:
60
+ _result = subprocess.run(
61
+ ["git", "-C", self.repo_path, "rev-parse", "--git-dir"],
62
+ capture_output=True,
63
+ text=True,
64
+ check=True,
65
+ timeout=10
66
+ )
67
+ except subprocess.TimeoutExpired:
68
+ raise ValueError(f"Timeout while validating git repository: {self.repo_path}")
69
+ except subprocess.CalledProcessError as e:
70
+ raise ValueError(f"Invalid git repository: {self.repo_path}. Error: {e.stderr}")
71
+
72
+ @property
73
+ def repos_names(self) -> List[str]:
74
+ """
75
+ Return the repository name.
76
+
77
+ Returns:
78
+ List[str]: List containing the repository name (single item).
79
+ """
80
+ repo_name = os.path.basename(self.repo_path)
81
+ return [repo_name]
82
+
83
+ def _run_git_log(self, extra_args: List[str] = None) -> List[Dict[str, Any]]:
84
+ """
85
+ Run git log command with common arguments and parse output.
86
+
87
+ Args:
88
+ extra_args (List[str], optional): Additional git log arguments.
89
+
90
+ Returns:
91
+ List[Dict[str, Any]]: Parsed commit entries.
92
+ """
93
+ args = [
94
+ "git",
95
+ "-C", self.repo_path,
96
+ "log",
97
+ "--pretty=format:%H|%an|%ad|%s",
98
+ "--date=iso",
99
+ "--all"
100
+ ]
101
+
102
+ if self.start_date:
103
+ args.extend(["--since", self.start_date.isoformat()])
104
+ if self.end_date:
105
+ args.extend(["--until", self.end_date.isoformat()])
106
+ if self.authors:
107
+ authors_filter = "|".join(self.authors)
108
+ args.extend(["--author", authors_filter])
109
+ if extra_args:
110
+ args.extend(extra_args)
111
+
112
+ try:
113
+ result = subprocess.run(
114
+ args,
115
+ capture_output=True,
116
+ text=True,
117
+ check=True,
118
+ timeout=120
119
+ )
120
+ return self._parse_git_log(result.stdout)
121
+ except subprocess.TimeoutExpired:
122
+ return []
123
+ except subprocess.CalledProcessError:
124
+ return []
125
+
126
+ def _parse_git_log(self, log_output: str) -> List[Dict[str, Any]]:
127
+ """
128
+ Parse git log output into structured data.
129
+
130
+ Args:
131
+ log_output (str): Raw git log output.
132
+
133
+ Returns:
134
+ List[Dict[str, Any]]: Parsed commit entries.
135
+ """
136
+ entries = []
137
+ for line in log_output.splitlines():
138
+ if not line.strip():
139
+ continue
140
+
141
+ try:
142
+ sha, author, date_str, message = line.split("|", 3)
143
+ timestamp = datetime.fromisoformat(date_str)
144
+
145
+ if self.start_date and timestamp < self.start_date:
146
+ continue
147
+ if self.end_date and timestamp > self.end_date:
148
+ continue
149
+
150
+ entries.append({
151
+ "type": "commit",
152
+ "repo": self.repos_names[0],
153
+ "message": message,
154
+ "sha": sha,
155
+ "author": author,
156
+ "timestamp": timestamp
157
+ })
158
+ except ValueError:
159
+ continue
160
+
161
+ return entries
162
+
163
+ def fetch_commits(self) -> List[Dict[str, Any]]:
164
+ """
165
+ Fetch commits from the local repository.
166
+
167
+ Returns:
168
+ List[Dict[str, Any]]: List of commit entries.
169
+ """
170
+ return self._run_git_log()
171
+
172
+ def fetch_pull_requests(self) -> List[Dict[str, Any]]:
173
+ """
174
+ Fetch pull requests (not applicable for local repositories).
175
+
176
+ Returns:
177
+ List[Dict[str, Any]]: Empty list (PRs are platform-specific).
178
+ """
179
+ return []
180
+
181
+ def fetch_issues(self) -> List[Dict[str, Any]]:
182
+ """
183
+ Fetch issues (not applicable for local repositories).
184
+
185
+ Returns:
186
+ List[Dict[str, Any]]: Empty list (issues are platform-specific).
187
+ """
188
+ return []
189
+
190
+ def fetch_releases(self) -> List[Dict[str, Any]]:
191
+ """
192
+ Fetch releases for the repository.
193
+ Not applicable for local repositories.
194
+
195
+ Raises:
196
+ NotImplementedError: Always, since release fetching is not supported for LocalFetcher.
197
+ """
198
+ raise NotImplementedError(
199
+ "Release fetching is not supported for local repositories (LocalFetcher)."
200
+ )
201
+
202
+ def get_branches(self) -> List[str]:
203
+ """
204
+ Get all branches in the local repository.
205
+
206
+ Returns:
207
+ List[str]: List of branch names (both local and remote).
208
+ """
209
+ try:
210
+ # Get local branches
211
+ result = subprocess.run(
212
+ ["git", "-C", self.repo_path, "branch", "--format=%(refname:short)"],
213
+ capture_output=True,
214
+ text=True,
215
+ check=True
216
+ )
217
+ branches = [b.strip() for b in result.stdout.splitlines() if b.strip()]
218
+
219
+ # Get remote branches
220
+ result_remote = subprocess.run(
221
+ ["git", "-C", self.repo_path, "branch", "-r", "--format=%(refname:short)"],
222
+ capture_output=True,
223
+ text=True,
224
+ check=True
225
+ )
226
+ remote_branches = [
227
+ b.strip() for b in result_remote.stdout.splitlines()
228
+ if b.strip() and not b.endswith('/HEAD')
229
+ ]
230
+
231
+ return branches + remote_branches
232
+ except subprocess.CalledProcessError:
233
+ return []
234
+
235
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
236
+ """
237
+ Get branches that can receive a pull request from the source branch.
238
+ Not applicable for local repositories.
239
+
240
+ Args:
241
+ source_branch (str): The source branch name.
242
+
243
+ Returns:
244
+ List[str]: Empty list (PRs are platform-specific).
245
+
246
+ Raises:
247
+ NotImplementedError: Always, since PR validation is not supported for LocalFetcher.
248
+ """
249
+ raise NotImplementedError(
250
+ "Pull request target branch validation is not supported for local repositories (LocalFetcher)."
251
+ )
252
+
253
+ def create_pull_request(
254
+ self,
255
+ head_branch: str,
256
+ base_branch: str,
257
+ title: str,
258
+ body: str,
259
+ draft: bool = False,
260
+ reviewers: Optional[List[str]] = None,
261
+ assignees: Optional[List[str]] = None,
262
+ labels: Optional[List[str]] = None
263
+ ) -> Dict[str, Any]:
264
+ """
265
+ Create a pull request between two branches.
266
+ Not applicable for local repositories.
267
+
268
+ Args:
269
+ head_branch: Source branch for the PR.
270
+ base_branch: Target branch for the PR.
271
+ title: PR title.
272
+ body: PR description.
273
+ draft: Whether to create as draft PR (default: False).
274
+ reviewers: List of reviewer usernames (optional).
275
+ assignees: List of assignee usernames (optional).
276
+ labels: List of label names (optional).
277
+
278
+ Returns:
279
+ Dict[str, Any]: Empty dict (PRs are platform-specific).
280
+
281
+ Raises:
282
+ NotImplementedError: Always, since PR creation is not supported for LocalFetcher.
283
+ """
284
+ raise NotImplementedError(
285
+ "Pull request creation is not supported for local repositories (LocalFetcher)."
286
+ )
287
+
288
+ def get_authors(self, repo_names: List[str]) -> List[Dict[str, str]]:
289
+ """
290
+ Retrieve unique authors from the local repository using git log.
291
+
292
+ Args:
293
+ repo_names: Not used for local fetcher (single repo only).
294
+
295
+ Returns:
296
+ List[Dict[str, str]]: List of unique author dictionaries with name and email.
297
+ """
298
+ authors_set = set()
299
+
300
+ try:
301
+ # Get authors from commit history
302
+ cmd = [
303
+ 'git', '-C', self.repo_path, 'log',
304
+ '--all',
305
+ '--format=%an|%ae'
306
+ ]
307
+
308
+ result = subprocess.run(
309
+ cmd,
310
+ capture_output=True,
311
+ text=True,
312
+ check=True
313
+ )
314
+
315
+ for line in result.stdout.strip().split('\n'):
316
+ if '|' in line:
317
+ name, email = line.split('|', 1)
318
+ authors_set.add((name.strip(), email.strip()))
319
+
320
+ # Also get committers
321
+ cmd_committer = [
322
+ 'git', '-C', self.repo_path, 'log',
323
+ '--all',
324
+ '--format=%cn|%ce'
325
+ ]
326
+
327
+ result_committer = subprocess.run(
328
+ cmd_committer,
329
+ capture_output=True,
330
+ text=True,
331
+ check=True
332
+ )
333
+
334
+ for line in result_committer.stdout.strip().split('\n'):
335
+ if '|' in line:
336
+ name, email = line.split('|', 1)
337
+ authors_set.add((name.strip(), email.strip()))
338
+
339
+ authors_list = [
340
+ {"name": name, "email": email}
341
+ for name, email in sorted(authors_set)
342
+ ]
343
+
344
+ return authors_list
345
+
346
+ except subprocess.CalledProcessError as e:
347
+ print(f"Git command failed: {e}")
348
+ return []
349
+ except Exception as e:
350
+ print(f"Error in get_authors: {e}")
351
+ return []
352
+
353
+ def get_current_author(self) -> Optional[Dict[str, str]]:
354
+ """
355
+ Retrieve the current git user's information from local configuration.
356
+
357
+ Returns:
358
+ Optional[Dict[str, str]]: Dictionary with 'name' and 'email' keys,
359
+ or None if not configured.
360
+ """
361
+ try:
362
+ # Get user name
363
+ result_name = subprocess.run(
364
+ ["git", "-C", self.repo_path, "config", "user.name"],
365
+ capture_output=True,
366
+ text=True,
367
+ check=True
368
+ )
369
+ user_name = result_name.stdout.strip()
370
+
371
+ # Get user email
372
+ result_email = subprocess.run(
373
+ ["git", "-C", self.repo_path, "config", "user.email"],
374
+ capture_output=True,
375
+ text=True,
376
+ check=True
377
+ )
378
+ user_email = result_email.stdout.strip()
379
+
380
+ if user_name and user_email:
381
+ return {
382
+ "name": user_name,
383
+ "email": user_email
384
+ }
385
+ return None
386
+ except subprocess.CalledProcessError:
387
+ return None
388
+ except Exception as e:
389
+ print(f"Error retrieving current author: {e}")
390
+ return None