git-recap 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -1,12 +1,29 @@
1
1
  from azure.devops.connection import Connection
2
2
  from msrest.authentication import BasicAuthentication
3
3
  from datetime import datetime
4
- from typing import List, Dict, Any
4
+ from typing import List, Dict, Any, Optional
5
5
  from git_recap.providers.base_fetcher import BaseFetcher
6
6
 
7
7
  class AzureFetcher(BaseFetcher):
8
+ """
9
+ Fetcher implementation for Azure DevOps repositories.
10
+
11
+ Supports fetching commits, pull requests, and issues.
12
+ Release fetching is not supported and will raise NotImplementedError.
13
+ """
14
+
8
15
  def __init__(self, pat: str, organization_url: str, start_date=None, end_date=None, repo_filter=None, authors=None):
9
- # authors should be passed as a list of unique names (e.g., email or unique id)
16
+ """
17
+ Initialize the AzureFetcher.
18
+
19
+ Args:
20
+ pat (str): Personal Access Token for Azure DevOps.
21
+ organization_url (str): The Azure DevOps organization URL.
22
+ start_date (datetime, optional): Start date for filtering entries.
23
+ end_date (datetime, optional): End date for filtering entries.
24
+ repo_filter (List[str], optional): List of repository names to filter.
25
+ authors (List[str], optional): List of author identifiers (e.g., email or unique id).
26
+ """
10
27
  super().__init__(pat, start_date, end_date, repo_filter, authors)
11
28
  self.organization_url = organization_url
12
29
  credentials = BasicAuthentication('', self.pat)
@@ -20,17 +37,37 @@ class AzureFetcher(BaseFetcher):
20
37
  self.authors = []
21
38
 
22
39
  def get_repos(self):
40
+ """
41
+ Retrieve all repositories in all projects for the organization.
42
+ Returns:
43
+ List of repository objects.
44
+ """
23
45
  projects = self.core_client.get_projects().value
24
46
  # Get all repositories in each project
25
47
  repos = [self.git_client.get_repositories(project.id) for project in projects]
26
48
  return repos
27
49
 
28
50
  @property
29
- def repos_names(self)->List[str]:
30
- "to be implemented later"
51
+ def repos_names(self) -> List[str]:
52
+ """
53
+ Return the list of repository names.
54
+
55
+ Returns:
56
+ List[str]: List of repository names.
57
+ """
58
+ # To be implemented if needed for UI or listing.
31
59
  ...
32
60
 
33
61
  def _filter_by_date(self, date_obj: datetime) -> bool:
62
+ """
63
+ Check if a datetime object is within the configured date range.
64
+
65
+ Args:
66
+ date_obj (datetime): The datetime to check.
67
+
68
+ Returns:
69
+ bool: True if within range, False otherwise.
70
+ """
34
71
  if self.start_date and date_obj < self.start_date:
35
72
  return False
36
73
  if self.end_date and date_obj > self.end_date:
@@ -38,11 +75,26 @@ class AzureFetcher(BaseFetcher):
38
75
  return True
39
76
 
40
77
  def _stop_fetching(self, date_obj: datetime) -> bool:
78
+ """
79
+ Determine if fetching should stop based on the date.
80
+
81
+ Args:
82
+ date_obj (datetime): The datetime to check.
83
+
84
+ Returns:
85
+ bool: True if should stop, False otherwise.
86
+ """
41
87
  if self.start_date and date_obj < self.start_date:
42
88
  return True
43
89
  return False
44
90
 
45
91
  def fetch_commits(self) -> List[Dict[str, Any]]:
92
+ """
93
+ Fetch commits for all repositories and authors.
94
+
95
+ Returns:
96
+ List[Dict[str, Any]]: List of commit entries.
97
+ """
46
98
  entries = []
47
99
  processed_commits = set()
48
100
  for repo in self.repos:
@@ -77,6 +129,12 @@ class AzureFetcher(BaseFetcher):
77
129
  return entries
78
130
 
79
131
  def fetch_pull_requests(self) -> List[Dict[str, Any]]:
132
+ """
133
+ Fetch pull requests and their associated commits for all repositories and authors.
134
+
135
+ Returns:
136
+ List[Dict[str, Any]]: List of pull request and commit_from_pr entries.
137
+ """
80
138
  entries = []
81
139
  processed_pr_commits = set()
82
140
  projects = self.core_client.get_projects().value
@@ -138,6 +196,12 @@ class AzureFetcher(BaseFetcher):
138
196
  return entries
139
197
 
140
198
  def fetch_issues(self) -> List[Dict[str, Any]]:
199
+ """
200
+ Fetch issues (work items) assigned to the configured authors.
201
+
202
+ Returns:
203
+ List[Dict[str, Any]]: List of issue entries.
204
+ """
141
205
  entries = []
142
206
  wit_client = self.connection.clients.get_work_item_tracking_client()
143
207
  # Query work items for each author using a simplified WIQL query.
@@ -160,4 +224,93 @@ class AzureFetcher(BaseFetcher):
160
224
  entries.append(entry)
161
225
  if self._stop_fetching(created_date):
162
226
  break
163
- return entries
227
+ return entries
228
+
229
+ def fetch_releases(self) -> List[Dict[str, Any]]:
230
+ """
231
+ Fetch releases for Azure DevOps repositories.
232
+
233
+ Not implemented for Azure DevOps.
234
+
235
+ Raises:
236
+ NotImplementedError: Always, since release fetching is not supported for AzureFetcher.
237
+ """
238
+ # If Azure DevOps release fetching is supported in the future, implement logic here.
239
+ raise NotImplementedError("Release fetching is not supported for Azure DevOps (AzureFetcher).")
240
+
241
+ def get_branches(self) -> List[str]:
242
+ """
243
+ Get all branches in the repository.
244
+
245
+ Returns:
246
+ List[str]: List of branch names.
247
+
248
+ Raises:
249
+ NotImplementedError: Always, since branch listing is not yet implemented for AzureFetcher.
250
+ """
251
+ # TODO: Implement get_branches() for Azure DevOps support
252
+ # This would use: git_client.get_branches(repository_id, project)
253
+ # and extract branch names from the returned objects
254
+ raise NotImplementedError("Branch listing is not yet implemented for Azure DevOps (AzureFetcher).")
255
+
256
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
257
+ """
258
+ Get branches that can receive a pull request from the source branch.
259
+
260
+ Validates that the source branch exists, filters out branches with existing
261
+ open PRs from source, excludes the source branch itself, and optionally
262
+ checks if source is ahead of target.
263
+
264
+ Args:
265
+ source_branch (str): The source branch name.
266
+
267
+ Returns:
268
+ List[str]: List of valid target branch names.
269
+
270
+ Raises:
271
+ NotImplementedError: Always, since PR target validation is not yet implemented for AzureFetcher.
272
+ """
273
+ # TODO: Implement get_valid_target_branches() for Azure DevOps support
274
+ # This would require:
275
+ # 1. Verify source_branch exists using git_client.get_branch()
276
+ # 2. Get all branches using get_branches()
277
+ # 3. Filter out source branch
278
+ # 4. Check for existing pull requests using git_client.get_pull_requests()
279
+ # 5. Filter out branches with existing open PRs from source
280
+ # 6. Optionally check branch policies and protection rules
281
+ raise NotImplementedError("Pull request target branch validation is not yet implemented for Azure DevOps (AzureFetcher).")
282
+
283
+ def create_pull_request(
284
+ self,
285
+ head_branch: str,
286
+ base_branch: str,
287
+ title: str,
288
+ body: str,
289
+ draft: bool = False,
290
+ reviewers: Optional[List[str]] = None,
291
+ assignees: Optional[List[str]] = None,
292
+ labels: Optional[List[str]] = None
293
+ ) -> Dict[str, Any]:
294
+ """
295
+ Create a pull request between two branches with optional metadata.
296
+
297
+ Args:
298
+ head_branch: Source branch for the PR.
299
+ base_branch: Target branch for the PR.
300
+ title: PR title.
301
+ body: PR description.
302
+ draft: Whether to create as draft PR (default: False).
303
+ reviewers: List of reviewer usernames (optional).
304
+ assignees: List of assignee usernames (optional).
305
+ labels: List of label names (optional).
306
+
307
+ Returns:
308
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
309
+
310
+ Raises:
311
+ NotImplementedError: Always, since PR creation is not yet implemented for AzureFetcher.
312
+ """
313
+ # TODO: Implement create_pull_request() for Azure DevOps support
314
+ # This would use: git_client.create_pull_request() with appropriate parameters
315
+ # Would need to handle reviewers, work item links (assignees), labels, and draft status
316
+ raise NotImplementedError("Pull request creation is not yet implemented for Azure DevOps (AzureFetcher).")
@@ -9,7 +9,7 @@ class BaseFetcher(ABC):
9
9
  start_date: Optional[datetime] = None,
10
10
  end_date: Optional[datetime] = None,
11
11
  repo_filter: Optional[List[str]] = None,
12
- authors: Optional[List[str]]=None
12
+ authors: Optional[List[str]] = None
13
13
  ):
14
14
  self.pat = pat
15
15
  if start_date is not None:
@@ -28,32 +28,148 @@ class BaseFetcher(ABC):
28
28
 
29
29
  @property
30
30
  @abstractmethod
31
- def repos_names(self)->List[str]:
31
+ def repos_names(self) -> List[str]:
32
+ """
33
+ Return the list of repository names accessible to this fetcher.
34
+
35
+ Returns:
36
+ List[str]: List of repository names.
37
+ """
32
38
  pass
33
-
39
+
34
40
  @abstractmethod
35
- def fetch_commits(self) -> List[str]:
41
+ def fetch_commits(self) -> List[Dict[str, Any]]:
42
+ """
43
+ Fetch commit entries for the configured repositories and authors.
44
+
45
+ Returns:
46
+ List[Dict[str, Any]]: List of commit entries.
47
+ """
36
48
  pass
37
49
 
38
50
  @abstractmethod
39
- def fetch_pull_requests(self) -> List[str]:
51
+ def fetch_pull_requests(self) -> List[Dict[str, Any]]:
52
+ """
53
+ Fetch pull request entries for the configured repositories and authors.
54
+
55
+ Returns:
56
+ List[Dict[str, Any]]: List of pull request entries.
57
+ """
40
58
  pass
41
59
 
42
60
  @abstractmethod
43
- def fetch_issues(self) -> List[str]:
61
+ def fetch_issues(self) -> List[Dict[str, Any]]:
62
+ """
63
+ Fetch issue entries for the configured repositories and authors.
64
+
65
+ Returns:
66
+ List[Dict[str, Any]]: List of issue entries.
67
+ """
44
68
  pass
45
69
 
70
+ @abstractmethod
71
+ def fetch_releases(self) -> List[Dict[str, Any]]:
72
+ """
73
+ Fetch releases for all repositories accessible to this fetcher.
74
+
75
+ Returns:
76
+ List[Dict[str, Any]]: List of releases, each as a structured dictionary.
77
+ The dictionary should include at least:
78
+ - tag_name: str
79
+ - name: str
80
+ - repo: str
81
+ - author: str
82
+ - published_at: datetime
83
+ - created_at: datetime
84
+ - draft: bool
85
+ - prerelease: bool
86
+ - body: str
87
+ - assets: List[Dict[str, Any]] (if available)
88
+ Raises:
89
+ NotImplementedError: If the provider does not support release fetching.
90
+ """
91
+ raise NotImplementedError("Release fetching is not implemented for this provider.")
92
+
93
+ @abstractmethod
94
+ def get_branches(self) -> List[str]:
95
+ """
96
+ Get all branches in the repository.
97
+
98
+ Returns:
99
+ List[str]: List of branch names.
100
+
101
+ Raises:
102
+ NotImplementedError: Subclasses must implement this method.
103
+ """
104
+ raise NotImplementedError("Subclasses must implement get_branches() to return all repository branches")
105
+
106
+ @abstractmethod
107
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
108
+ """
109
+ Get branches that can receive a pull request from the source branch.
110
+
111
+ Validates that the source branch exists, filters out branches with existing
112
+ open PRs from source, excludes the source branch itself, and optionally
113
+ checks if source is ahead of target.
114
+
115
+ Args:
116
+ source_branch (str): The source branch name.
117
+
118
+ Returns:
119
+ List[str]: List of valid target branch names.
120
+
121
+ Raises:
122
+ NotImplementedError: Subclasses must implement this method.
123
+ """
124
+ raise NotImplementedError("Subclasses must implement get_valid_target_branches() to return valid PR target branches for the given source branch")
125
+
126
+ @abstractmethod
127
+ def create_pull_request(
128
+ self,
129
+ head_branch: str,
130
+ base_branch: str,
131
+ title: str,
132
+ body: str,
133
+ draft: bool = False,
134
+ reviewers: Optional[List[str]] = None,
135
+ assignees: Optional[List[str]] = None,
136
+ labels: Optional[List[str]] = None
137
+ ) -> Dict[str, Any]:
138
+ """
139
+ Create a pull request between two branches with optional metadata.
140
+
141
+ Args:
142
+ head_branch: Source branch for the PR.
143
+ base_branch: Target branch for the PR.
144
+ title: PR title.
145
+ body: PR description.
146
+ draft: Whether to create as draft PR (default: False).
147
+ reviewers: List of reviewer usernames (optional).
148
+ assignees: List of assignee usernames (optional).
149
+ labels: List of label names (optional).
150
+
151
+ Returns:
152
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
153
+
154
+ Raises:
155
+ NotImplementedError: Subclasses must implement this method.
156
+ """
157
+ raise NotImplementedError("Subclasses must implement create_pull_request() to create a pull request with the specified parameters")
158
+
46
159
  def get_authored_messages(self) -> List[Dict[str, Any]]:
47
160
  """
48
161
  Aggregates all commit, pull request, and issue entries into a single list,
49
162
  ensuring no duplicate commits (based on SHA) are present, and then sorts
50
163
  them in chronological order based on their timestamp.
164
+
165
+ Returns:
166
+ List[Dict[str, Any]]: Aggregated and sorted list of entries.
51
167
  """
52
168
  commit_entries = self.fetch_commits()
53
169
  pr_entries = self.fetch_pull_requests()
54
170
  try:
55
171
  issue_entries = self.fetch_issues()
56
- except Exception as e:
172
+ except Exception:
57
173
  issue_entries = []
58
174
 
59
175
  all_entries = pr_entries + commit_entries + issue_entries
@@ -75,11 +191,17 @@ class BaseFetcher(ABC):
75
191
  # Sort all entries by their timestamp.
76
192
  final_entries.sort(key=lambda x: x["timestamp"])
77
193
  return self.convert_timestamps_to_str(final_entries)
78
-
194
+
79
195
  @staticmethod
80
196
  def convert_timestamps_to_str(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
81
197
  """
82
198
  Converts the timestamp field from datetime to string format for each entry in the list.
199
+
200
+ Args:
201
+ entries (List[Dict[str, Any]]): List of entries with possible datetime timestamps.
202
+
203
+ Returns:
204
+ List[Dict[str, Any]]: Entries with timestamps as ISO-formatted strings.
83
205
  """
84
206
  for entry in entries:
85
207
  if isinstance(entry.get("timestamp"), datetime):
@@ -1,18 +1,29 @@
1
1
  from github import Github
2
+ from github import GithubException
2
3
  from datetime import datetime
3
- from typing import List, Dict, Any
4
+ from typing import List, Dict, Any, Optional
4
5
  from git_recap.providers.base_fetcher import BaseFetcher
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
5
10
 
6
11
  class GitHubFetcher(BaseFetcher):
12
+ """
13
+ Fetcher implementation for GitHub repositories.
14
+
15
+ Supports fetching commits, pull requests, issues, and releases.
16
+ """
17
+
7
18
  def __init__(self, pat: str, start_date=None, end_date=None, repo_filter=None, authors=None):
8
19
  super().__init__(pat, start_date, end_date, repo_filter, authors)
9
20
  self.github = Github(self.pat)
10
- self.user = self.github.get_user()
21
+ self.user = self.github.get_user()
11
22
  self.repos = self.user.get_repos(affiliation="owner,collaborator,organization_member")
12
23
  self.authors.append(self.user.login)
13
24
 
14
25
  @property
15
- def repos_names(self)->List[str]:
26
+ def repos_names(self) -> List[str]:
16
27
  return [repo.name for repo in self.repos]
17
28
 
18
29
  def _stop_fetching(self, date_obj: datetime) -> bool:
@@ -53,6 +64,32 @@ class GitHubFetcher(BaseFetcher):
53
64
  break
54
65
  return entries
55
66
 
67
+ def fetch_branch_diff_commits(self, source_branch: str, target_branch: str) -> List[Dict[str, Any]]:
68
+ entries = []
69
+ processed_commits = set()
70
+ for repo in self.repos:
71
+ if self.repo_filter and repo.name not in self.repo_filter:
72
+ continue
73
+ try:
74
+ comparison = repo.compare(target_branch, source_branch)
75
+ for commit in comparison.commits:
76
+ commit_date = commit.commit.author.date
77
+ sha = commit.sha
78
+ if sha not in processed_commits:
79
+ entry = {
80
+ "type": "commit",
81
+ "repo": repo.name,
82
+ "message": commit.commit.message.strip(),
83
+ "timestamp": commit_date,
84
+ "sha": sha,
85
+ }
86
+ entries.append(entry)
87
+ processed_commits.add(sha)
88
+ except GithubException as e:
89
+ logger.error(f"Failed to compare branches in {repo.name}: {str(e)}")
90
+ continue
91
+ return entries
92
+
56
93
  def fetch_pull_requests(self) -> List[Dict[str, Any]]:
57
94
  entries = []
58
95
  # Maintain a local set to skip duplicate commits already captured in a PR.
@@ -101,7 +138,6 @@ class GitHubFetcher(BaseFetcher):
101
138
  break
102
139
  return entries
103
140
 
104
-
105
141
  def fetch_issues(self) -> List[Dict[str, Any]]:
106
142
  entries = []
107
143
  issues = self.user.get_issues()
@@ -117,4 +153,256 @@ class GitHubFetcher(BaseFetcher):
117
153
  entries.append(entry)
118
154
  if self._stop_fetching(issue_date):
119
155
  break
120
- return entries
156
+ return entries
157
+
158
+ def fetch_releases(self) -> List[Dict[str, Any]]:
159
+ """
160
+ Fetch releases for all repositories accessible to the user.
161
+
162
+ Returns:
163
+ List[Dict[str, Any]]: List of releases, each as a structured dictionary with:
164
+ - tag_name: str
165
+ - name: str
166
+ - repo: str
167
+ - author: str
168
+ - published_at: datetime
169
+ - created_at: datetime
170
+ - draft: bool
171
+ - prerelease: bool
172
+ - body: str
173
+ - assets: List[Dict[str, Any]] (each with name, size, download_url, content_type, etc.)
174
+ """
175
+ releases = []
176
+ for repo in self.repos:
177
+ if self.repo_filter and repo.name not in self.repo_filter:
178
+ continue
179
+ try:
180
+ for rel in repo.get_releases():
181
+ # Compose asset list
182
+ assets = []
183
+ for asset in rel.get_assets():
184
+ assets.append({
185
+ "name": asset.name,
186
+ "size": asset.size,
187
+ "download_url": asset.browser_download_url,
188
+ "content_type": asset.content_type,
189
+ "created_at": asset.created_at,
190
+ "updated_at": asset.updated_at,
191
+ })
192
+ release_entry = {
193
+ "tag_name": rel.tag_name,
194
+ "name": rel.title if hasattr(rel, "title") else rel.name,
195
+ "repo": repo.name,
196
+ "author": rel.author.login if rel.author else None,
197
+ "published_at": rel.published_at,
198
+ "created_at": rel.created_at,
199
+ "draft": rel.draft,
200
+ "prerelease": rel.prerelease,
201
+ "body": rel.body,
202
+ "assets": assets,
203
+ }
204
+ releases.append(release_entry)
205
+ except Exception:
206
+ # If fetching releases fails for a repo, skip it (could be permissions or no releases)
207
+ continue
208
+ return releases
209
+
210
+ def get_branches(self) -> List[str]:
211
+ """
212
+ Get all branches in the repository.
213
+ Returns:
214
+ List[str]: List of branch names.
215
+ Raises:
216
+ Exception: If API rate limits are exceeded or authentication fails.
217
+ """
218
+ logger.debug("Fetching branches from all accessible repositories")
219
+ try:
220
+ branches = []
221
+ for repo in self.repos:
222
+ if self.repo_filter and repo.name not in self.repo_filter:
223
+ continue
224
+ logger.debug(f"Fetching branches for repository: {repo.name}")
225
+ repo_branches = repo.get_branches()
226
+ for branch in repo_branches:
227
+ branches.append(branch.name)
228
+ logger.debug(f"Successfully fetched {len(branches)} branches")
229
+ return branches
230
+ except GithubException as e:
231
+ if e.status == 403:
232
+ logger.error(f"Rate limit exceeded or authentication failed: {str(e)}")
233
+ raise Exception(f"Failed to fetch branches: Rate limit exceeded or authentication failed - {str(e)}")
234
+ elif e.status == 401:
235
+ logger.error(f"Authentication failed: {str(e)}")
236
+ raise Exception(f"Failed to fetch branches: Authentication failed - {str(e)}")
237
+ else:
238
+ logger.error(f"GitHub API error while fetching branches: {str(e)}")
239
+ raise Exception(f"Failed to fetch branches: {str(e)}")
240
+ except Exception as e:
241
+ logger.error(f"Unexpected error while fetching branches: {str(e)}")
242
+ raise Exception(f"Failed to fetch branches: {str(e)}")
243
+
244
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
245
+ """
246
+ Get branches that can receive a pull request from the source branch.
247
+ Validates that the source branch exists, filters out branches with existing
248
+ open PRs from source, excludes the source branch itself, and optionally
249
+ checks if source is ahead of target.
250
+ Args:
251
+ source_branch (str): The source branch name.
252
+ Returns:
253
+ List[str]: List of valid target branch names.
254
+ Raises:
255
+ ValueError: If source branch does not exist.
256
+ Exception: If API errors occur during validation.
257
+ """
258
+ logger.debug(f"Validating target branches for source branch: {source_branch}")
259
+ try:
260
+ all_branches = self.get_branches()
261
+ if source_branch not in all_branches:
262
+ logger.error(f"Source branch '{source_branch}' does not exist")
263
+ raise ValueError(f"Source branch '{source_branch}' does not exist")
264
+ valid_targets = []
265
+ for repo in self.repos:
266
+ if self.repo_filter and repo.name not in self.repo_filter:
267
+ continue
268
+ logger.debug(f"Processing repository: {repo.name}")
269
+ repo_branches = [branch.name for branch in repo.get_branches()]
270
+ # Get existing open PRs from source branch
271
+ try:
272
+ open_prs = repo.get_pulls(state='open', head=source_branch)
273
+ except GithubException as e:
274
+ logger.error(f"GitHub API error while getting PRs: {str(e)}")
275
+ raise Exception(f"Failed to validate target branches: {str(e)}")
276
+ existing_pr_targets = set()
277
+ for pr in open_prs:
278
+ existing_pr_targets.add(pr.base.ref)
279
+ logger.debug(f"Found existing PR from {source_branch} to {pr.base.ref}")
280
+ for branch_name in repo_branches:
281
+ if branch_name == source_branch:
282
+ logger.debug(f"Excluding source branch: {branch_name}")
283
+ continue
284
+ if branch_name in existing_pr_targets:
285
+ logger.debug(f"Excluding branch with existing PR: {branch_name}")
286
+ continue
287
+ # Optionally check if source is ahead of target (performance cost)
288
+ valid_targets.append(branch_name)
289
+ logger.debug(f"Valid target branch: {branch_name}")
290
+ logger.debug(f"Found {len(valid_targets)} valid target branches")
291
+ return valid_targets
292
+ except ValueError:
293
+ raise
294
+ except GithubException as e:
295
+ logger.error(f"GitHub API error while validating target branches: {str(e)}")
296
+ raise Exception(f"Failed to validate target branches: {str(e)}")
297
+ except Exception as e:
298
+ logger.error(f"Unexpected error while validating target branches: {str(e)}")
299
+ raise Exception(f"Failed to validate target branches: {str(e)}")
300
+
301
+ def create_pull_request(
302
+ self,
303
+ head_branch: str,
304
+ base_branch: str,
305
+ title: str,
306
+ body: str,
307
+ draft: bool = False,
308
+ reviewers: Optional[List[str]] = None,
309
+ assignees: Optional[List[str]] = None,
310
+ labels: Optional[List[str]] = None
311
+ ) -> Dict[str, Any]:
312
+ """
313
+ Create a pull request between two branches with optional metadata.
314
+ Args:
315
+ head_branch: Source branch for the PR.
316
+ base_branch: Target branch for the PR.
317
+ title: PR title.
318
+ body: PR description.
319
+ draft: Whether to create as draft PR (default: False).
320
+ reviewers: List of reviewer usernames (optional).
321
+ assignees: List of assignee usernames (optional).
322
+ labels: List of label names (optional).
323
+ Returns:
324
+ Dict[str, Any]: Dictionary containing PR metadata or error information.
325
+ Raises:
326
+ ValueError: If branches don't exist or PR already exists.
327
+ """
328
+ logger.info(f"Creating pull request from {head_branch} to {base_branch}")
329
+ try:
330
+ all_branches = self.get_branches()
331
+ if head_branch not in all_branches:
332
+ logger.error(f"Head branch '{head_branch}' does not exist")
333
+ raise ValueError(f"Head branch '{head_branch}' does not exist")
334
+ if base_branch not in all_branches:
335
+ logger.error(f"Base branch '{base_branch}' does not exist")
336
+ raise ValueError(f"Base branch '{base_branch}' does not exist")
337
+ for repo in self.repos:
338
+ if self.repo_filter and repo.name not in self.repo_filter:
339
+ continue
340
+ logger.debug(f"Checking for existing PRs in repository: {repo.name}")
341
+ try:
342
+ existing_prs = repo.get_pulls(state='open', head=head_branch, base=base_branch)
343
+ except GithubException as e:
344
+ logger.error(f"GitHub API error while getting PRs: {str(e)}")
345
+ raise
346
+ if hasattr(existing_prs, "totalCount") and existing_prs.totalCount > 0:
347
+ logger.error(f"Pull request already exists from {head_branch} to {base_branch}")
348
+ raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}")
349
+ elif isinstance(existing_prs, list) and len(existing_prs) > 0:
350
+ logger.error(f"Pull request already exists from {head_branch} to {base_branch}")
351
+ raise ValueError(f"Pull request already exists from {head_branch} to {base_branch}")
352
+ logger.info(f"Creating pull request in repository: {repo.name}")
353
+ try:
354
+ pr = repo.create_pull(
355
+ title=title,
356
+ body=body,
357
+ head=head_branch,
358
+ base=base_branch,
359
+ draft=draft
360
+ )
361
+ logger.info(f"Pull request created successfully: {pr.html_url}")
362
+ if reviewers and len(reviewers) > 0:
363
+ try:
364
+ logger.debug(f"Adding reviewers: {reviewers}")
365
+ pr.create_review_request(reviewers=reviewers)
366
+ logger.info(f"Successfully added reviewers: {reviewers}")
367
+ except GithubException as e:
368
+ logger.warning(f"Failed to add reviewers: {str(e)}")
369
+ if assignees and len(assignees) > 0:
370
+ try:
371
+ logger.debug(f"Adding assignees: {assignees}")
372
+ pr.add_to_assignees(*assignees)
373
+ logger.info(f"Successfully added assignees: {assignees}")
374
+ except GithubException as e:
375
+ logger.warning(f"Failed to add assignees: {str(e)}")
376
+ if labels and len(labels) > 0:
377
+ try:
378
+ logger.debug(f"Adding labels: {labels}")
379
+ pr.add_to_labels(*labels)
380
+ logger.info(f"Successfully added labels: {labels}")
381
+ except GithubException as e:
382
+ logger.warning(f"Failed to add labels: {str(e)}")
383
+ return {
384
+ "url": pr.html_url,
385
+ "number": pr.number,
386
+ "state": pr.state,
387
+ "success": True
388
+ }
389
+ except GithubException as e:
390
+ if e.status == 404:
391
+ logger.error(f"Branch not found: {str(e)}")
392
+ raise ValueError(f"Branch not found: {str(e)}")
393
+ elif e.status == 403:
394
+ logger.error(f"Permission denied: {str(e)}")
395
+ raise GithubException(e.status, f"Permission denied: {str(e)}", e.headers)
396
+ elif e.status == 422:
397
+ logger.error(f"Merge conflict or validation error: {str(e)}")
398
+ raise ValueError(f"Merge conflict or validation error: {str(e)}")
399
+ else:
400
+ logger.error(f"GitHub API error: {str(e)}")
401
+ raise
402
+ logger.error("No repository found to create pull request")
403
+ raise ValueError("No repository found to create pull request")
404
+ except (ValueError, GithubException):
405
+ raise
406
+ except Exception as e:
407
+ logger.error(f"Unexpected error while creating pull request: {str(e)}")
408
+ raise Exception(f"Failed to create pull request: {str(e)}")
@@ -1,14 +1,40 @@
1
1
  import gitlab
2
2
  from datetime import datetime
3
- from typing import List, Dict, Any
3
+ from typing import List, Dict, Any, Optional
4
4
  from git_recap.providers.base_fetcher import BaseFetcher
5
5
 
6
6
  class GitLabFetcher(BaseFetcher):
7
- def __init__(self, pat: str, url: str = 'https://gitlab.com', start_date=None, end_date=None, repo_filter=None, authors=None):
7
+ """
8
+ Fetcher implementation for GitLab repositories.
9
+
10
+ Supports fetching commits, merge requests (pull requests), and issues.
11
+ Release fetching is not supported and will raise NotImplementedError.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ pat: str,
17
+ url: str = 'https://gitlab.com',
18
+ start_date=None,
19
+ end_date=None,
20
+ repo_filter=None,
21
+ authors=None
22
+ ):
23
+ """
24
+ Initialize the GitLabFetcher.
25
+
26
+ Args:
27
+ pat (str): Personal Access Token for GitLab.
28
+ url (str): The GitLab instance URL.
29
+ start_date (datetime, optional): Start date for filtering entries.
30
+ end_date (datetime, optional): End date for filtering entries.
31
+ repo_filter (List[str], optional): List of repository names to filter.
32
+ authors (List[str], optional): List of author usernames.
33
+ """
8
34
  super().__init__(pat, start_date, end_date, repo_filter, authors)
9
35
  self.gl = gitlab.Gitlab(url, private_token=self.pat)
10
36
  self.gl.auth()
11
- # Instead of only owned projects, retrieve projects where you're a member.
37
+ # Retrieve projects where the user is a member.
12
38
  self.projects = self.gl.projects.list(membership=True, all=True)
13
39
  # Default to the authenticated user's username if no authors are provided.
14
40
  if authors is None:
@@ -17,11 +43,12 @@ class GitLabFetcher(BaseFetcher):
17
43
  self.authors = authors
18
44
 
19
45
  @property
20
- def repos_names(self)->List[str]:
21
- "to be implemented later"
46
+ def repos_names(self) -> List[str]:
47
+ """Return the list of repository names."""
22
48
  return [project.name for project in self.projects]
23
49
 
24
50
  def _filter_by_date(self, date_str: str) -> bool:
51
+ """Check if a date string is within the configured date range."""
25
52
  date_obj = datetime.fromisoformat(date_str)
26
53
  if self.start_date and date_obj < self.start_date:
27
54
  return False
@@ -30,12 +57,19 @@ class GitLabFetcher(BaseFetcher):
30
57
  return True
31
58
 
32
59
  def _stop_fetching(self, date_str: str) -> bool:
60
+ """Determine if fetching should stop based on the date string."""
33
61
  date_obj = datetime.fromisoformat(date_str)
34
62
  if self.start_date and date_obj < self.start_date:
35
63
  return True
36
64
  return False
37
65
 
38
66
  def fetch_commits(self) -> List[Dict[str, Any]]:
67
+ """
68
+ Fetch commits for all projects and authors.
69
+
70
+ Returns:
71
+ List[Dict[str, Any]]: List of commit entries.
72
+ """
39
73
  entries = []
40
74
  processed_commits = set()
41
75
  for project in self.projects:
@@ -63,6 +97,12 @@ class GitLabFetcher(BaseFetcher):
63
97
  return entries
64
98
 
65
99
  def fetch_pull_requests(self) -> List[Dict[str, Any]]:
100
+ """
101
+ Fetch merge requests (pull requests) and their associated commits for all projects and authors.
102
+
103
+ Returns:
104
+ List[Dict[str, Any]]: List of pull request and commit_from_pr entries.
105
+ """
66
106
  entries = []
67
107
  processed_pr_commits = set()
68
108
  for project in self.projects:
@@ -109,6 +149,12 @@ class GitLabFetcher(BaseFetcher):
109
149
  return entries
110
150
 
111
151
  def fetch_issues(self) -> List[Dict[str, Any]]:
152
+ """
153
+ Fetch issues assigned to the authenticated user for all projects.
154
+
155
+ Returns:
156
+ List[Dict[str, Any]]: List of issue entries.
157
+ """
112
158
  entries = []
113
159
  for project in self.projects:
114
160
  if self.repo_filter and project.name not in self.repo_filter:
@@ -126,4 +172,78 @@ class GitLabFetcher(BaseFetcher):
126
172
  entries.append(entry)
127
173
  if self._stop_fetching(issue_date):
128
174
  break
129
- return entries
175
+ return entries
176
+
177
+ def fetch_releases(self) -> List[Dict[str, Any]]:
178
+ """
179
+ Fetch releases for GitLab repositories.
180
+
181
+ Not implemented for GitLabFetcher.
182
+
183
+ Raises:
184
+ NotImplementedError: Always, since release fetching is not supported for GitLabFetcher.
185
+ """
186
+ raise NotImplementedError("Release fetching is not supported for GitLab (GitLabFetcher).")
187
+
188
+ def get_branches(self) -> List[str]:
189
+ """
190
+ Get all branches in the repository.
191
+
192
+ Returns:
193
+ List[str]: List of branch names.
194
+
195
+ Raises:
196
+ NotImplementedError: Always, since branch listing is not yet implemented for GitLabFetcher.
197
+ """
198
+ raise NotImplementedError("Branch listing is not yet implemented for GitLab (GitLabFetcher).")
199
+
200
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
201
+ """
202
+ Get branches that can receive a pull request from the source branch.
203
+
204
+ Validates that the source branch exists, filters out branches with existing
205
+ open PRs from source, excludes the source branch itself, and optionally
206
+ checks if source is ahead of target.
207
+
208
+ Args:
209
+ source_branch (str): The source branch name.
210
+
211
+ Returns:
212
+ List[str]: List of valid target branch names.
213
+
214
+ Raises:
215
+ NotImplementedError: Always, since PR target validation is not yet implemented for GitLabFetcher.
216
+ """
217
+ raise NotImplementedError("Pull request target branch validation is not yet implemented for GitLab (GitLabFetcher).")
218
+
219
+ def create_pull_request(
220
+ self,
221
+ head_branch: str,
222
+ base_branch: str,
223
+ title: str,
224
+ body: str,
225
+ draft: bool = False,
226
+ reviewers: Optional[List[str]] = None,
227
+ assignees: Optional[List[str]] = None,
228
+ labels: Optional[List[str]] = None
229
+ ) -> Dict[str, Any]:
230
+ """
231
+ Create a pull request (merge request) between two branches with optional metadata.
232
+
233
+ Args:
234
+ head_branch: Source branch for the PR.
235
+ base_branch: Target branch for the PR.
236
+ title: PR title.
237
+ body: PR description.
238
+ draft: Whether to create as draft PR (default: False).
239
+ reviewers: List of reviewer usernames (optional).
240
+ assignees: List of assignee usernames (optional).
241
+ labels: List of label names (optional).
242
+
243
+ Returns:
244
+ Dict[str, Any]: Dictionary containing PR metadata (url, number, state, success) or error information.
245
+
246
+ Raises:
247
+ NotImplementedError: Always, since PR creation is not yet implemented for GitLabFetcher.
248
+ """
249
+ raise NotImplementedError("Pull request (merge request) creation is not yet implemented for GitLab (GitLabFetcher).")
@@ -11,14 +11,14 @@ from git_recap.providers.base_fetcher import BaseFetcher
11
11
 
12
12
  class URLFetcher(BaseFetcher):
13
13
  """Fetcher implementation for generic Git repository URLs."""
14
-
14
+
15
15
  GIT_URL_PATTERN = re.compile(
16
16
  r'^(?:http|https|git|ssh)://' # Protocol
17
17
  r'(?:\S+@)?' # Optional username
18
18
  r'([^/]+)' # Domain
19
19
  r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path
20
20
  )
21
-
21
+
22
22
  def __init__(
23
23
  self,
24
24
  url: str,
@@ -52,7 +52,7 @@ class URLFetcher(BaseFetcher):
52
52
  """Validate the Git repository URL using git ls-remote."""
53
53
  if not self.GIT_URL_PATTERN.match(self.url):
54
54
  raise ValueError(f"Invalid Git repository URL format: {self.url}")
55
-
55
+
56
56
  try:
57
57
  result = subprocess.run(
58
58
  ["git", "ls-remote", self.url],
@@ -80,7 +80,7 @@ class URLFetcher(BaseFetcher):
80
80
  text=True,
81
81
  timeout=300
82
82
  )
83
-
83
+
84
84
  # Fetch all branches
85
85
  subprocess.run(
86
86
  ["git", "-C", self.temp_dir, "fetch", "--all"],
@@ -89,7 +89,7 @@ class URLFetcher(BaseFetcher):
89
89
  text=True,
90
90
  timeout=300
91
91
  )
92
-
92
+
93
93
  # Verify the cloned repository has at least one commit
94
94
  verify_result = subprocess.run(
95
95
  ["git", "-C", self.temp_dir, "rev-list", "--count", "--all"],
@@ -99,7 +99,7 @@ class URLFetcher(BaseFetcher):
99
99
  )
100
100
  if int(verify_result.stdout.strip()) == 0:
101
101
  raise ValueError("Cloned repository has no commits")
102
-
102
+
103
103
  except subprocess.TimeoutExpired:
104
104
  raise RuntimeError("Repository cloning timed out")
105
105
  except subprocess.CalledProcessError as e:
@@ -113,23 +113,23 @@ class URLFetcher(BaseFetcher):
113
113
  """Return list of repository names (single item for URL fetcher)."""
114
114
  if not self.temp_dir:
115
115
  return []
116
-
116
+
117
117
  match = self.GIT_URL_PATTERN.match(self.url)
118
118
  if not match:
119
119
  repo_name = self.url.split('/')[-1]
120
120
  return [repo_name]
121
-
121
+
122
122
  repo_name = match.group(2).split('/')[-1]
123
123
  if repo_name.endswith(".git"):
124
124
  repo_name = repo_name[:-4]
125
-
125
+
126
126
  return [repo_name]
127
127
 
128
128
  def _get_all_branches(self) -> List[str]:
129
129
  """Get list of all remote branches in the repository."""
130
130
  if not self.temp_dir:
131
131
  return []
132
-
132
+
133
133
  try:
134
134
  result = subprocess.run(
135
135
  ["git", "-C", self.temp_dir, "branch", "-r", "--format=%(refname:short)"],
@@ -187,11 +187,11 @@ class URLFetcher(BaseFetcher):
187
187
  for line in log_output.splitlines():
188
188
  if not line.strip():
189
189
  continue
190
-
190
+
191
191
  try:
192
192
  sha, author, date_str, message = line.split("|", 3)
193
193
  timestamp = datetime.fromisoformat(date_str)
194
-
194
+
195
195
  if self.start_date and timestamp < self.start_date:
196
196
  continue
197
197
  if self.end_date and timestamp > self.end_date:
@@ -207,7 +207,7 @@ class URLFetcher(BaseFetcher):
207
207
  })
208
208
  except ValueError:
209
209
  continue # Skip malformed log entries
210
-
210
+
211
211
  return entries
212
212
 
213
213
  def fetch_commits(self) -> List[Dict[str, Any]]:
@@ -222,6 +222,74 @@ class URLFetcher(BaseFetcher):
222
222
  """Fetch issues (not implemented for generic Git URLs)."""
223
223
  return []
224
224
 
225
+ def fetch_releases(self) -> List[Dict[str, Any]]:
226
+ """
227
+ Fetch releases for the repository.
228
+ Not implemented for generic Git URLs.
229
+ Raises:
230
+ NotImplementedError: Always, since release fetching is not supported for URLFetcher.
231
+ """
232
+ raise NotImplementedError("Release fetching is not supported for generic Git URLs (URLFetcher).")
233
+
234
+ def get_branches(self) -> List[str]:
235
+ """
236
+ Get all branches in the repository.
237
+
238
+ Returns:
239
+ List[str]: List of branch names.
240
+
241
+ Raises:
242
+ NotImplementedError: Always, since branch listing is not yet implemented for URLFetcher.
243
+ """
244
+ raise NotImplementedError("Branch listing is not yet implemented for generic Git URLs (URLFetcher).")
245
+
246
+ def get_valid_target_branches(self, source_branch: str) -> List[str]:
247
+ """
248
+ Get branches that can receive a pull request from the source branch.
249
+
250
+ Args:
251
+ source_branch (str): The source branch name.
252
+
253
+ Returns:
254
+ List[str]: List of valid target branch names.
255
+
256
+ Raises:
257
+ NotImplementedError: Always, since PR target validation is not supported for URLFetcher.
258
+ """
259
+ raise NotImplementedError("Pull request target branch validation is not supported for generic Git URLs (URLFetcher).")
260
+
261
+ def create_pull_request(
262
+ self,
263
+ head_branch: str,
264
+ base_branch: str,
265
+ title: str,
266
+ body: str,
267
+ draft: bool = False,
268
+ reviewers: Optional[List[str]] = None,
269
+ assignees: Optional[List[str]] = None,
270
+ labels: Optional[List[str]] = None
271
+ ) -> Dict[str, Any]:
272
+ """
273
+ Create a pull request between two branches.
274
+
275
+ Args:
276
+ head_branch: Source branch for the PR.
277
+ base_branch: Target branch for the PR.
278
+ title: PR title.
279
+ body: PR description.
280
+ draft: Whether to create as draft PR (default: False).
281
+ reviewers: List of reviewer usernames (optional).
282
+ assignees: List of assignee usernames (optional).
283
+ labels: List of label names (optional).
284
+
285
+ Returns:
286
+ Dict[str, Any]: Dictionary containing PR metadata or error information.
287
+
288
+ Raises:
289
+ NotImplementedError: Always, since PR creation is not supported for URLFetcher.
290
+ """
291
+ raise NotImplementedError("Pull request creation is not supported for generic Git URLs (URLFetcher).")
292
+
225
293
  def clear(self) -> None:
226
294
  """Clean up temporary directory."""
227
295
  if self.temp_dir and os.path.exists(self.temp_dir):
git_recap/utils.py CHANGED
@@ -54,40 +54,94 @@ def parse_entries_to_txt(entries: List[Dict[str, Any]]) -> str:
54
54
 
55
55
  return "\n".join(lines)
56
56
 
57
- # Example usage:
58
- if __name__ == "__main__":
59
- # Assuming `output` is the list of dict entries from your fetcher.
60
- output = [
61
- {
62
- "type": "commit_from_pr",
63
- "repo": "AiCore",
64
- "message": "feat: update TODOs for ObservabilityDashboard with new input/output tokens and cross workspace analysis",
65
- "timestamp": "2025-03-14T00:17:02+00:00",
66
- "sha": "d1f185f09e4fcb775374b9468755b8463c94a605",
67
- "pr_title": "Unified ai integration error monitoring"
68
- },
69
- {
70
- "type": "commit_from_pr",
71
- "repo": "AiCore",
72
- "message": "feat: enhance token usage visualization in ObservabilityDashboard with grouped bar chart",
73
- "timestamp": "2025-03-15T00:20:15+00:00",
74
- "sha": "875457b9c80076d821f36cc646ec354ef5124088",
75
- "pr_title": "Unified ai integration error monitoring"
76
- },
77
- {
78
- "type": "pull_request",
79
- "repo": "AiCore",
80
- "message": "Unified ai integration error monitoring",
81
- "timestamp": "2025-03-15T21:47:13+00:00",
82
- "pr_number": 5
83
- },
84
- {
85
- "type": "commit",
86
- "repo": "AiCore",
87
- "message": "feat: update openai package version to 1.66.3 in requirements.txt and setup.py",
88
- "timestamp": "2025-03-15T23:22:28+00:00",
89
- "sha": "9f7e30ebcca8c909274dd8ca91fcfbd17bbf9195"
90
- },
91
- ]
92
- context_txt = parse_entries_to_txt(output)
93
- print(context_txt)
57
+ def parse_releases_to_txt(releases: List[Dict[str, Any]]) -> str:
58
+ """
59
+ Groups releases by day (YYYY-MM-DD, using published_at or created_at) and produces a plain text summary.
60
+
61
+ Each day's header is the date string, followed by a clear, LLM-friendly separator between releases:
62
+ - tag name and release name
63
+ - repo name
64
+ - author
65
+ - draft/prerelease status
66
+ - body/notes (if present)
67
+ - assets (if present)
68
+ """
69
+ grouped = defaultdict(list)
70
+ for rel in releases:
71
+ ts = rel.get("published_at") or rel.get("created_at")
72
+ if isinstance(ts, str):
73
+ dt = datetime.fromisoformat(ts)
74
+ else:
75
+ dt = ts
76
+ day = dt.strftime("%Y-%m-%d")
77
+ grouped[day].append(rel)
78
+
79
+ sorted_days = sorted(grouped.keys())
80
+
81
+ lines = []
82
+ for day in sorted_days:
83
+ lines.append(f"Date: {day}")
84
+ day_releases = sorted(grouped[day], key=lambda x: x.get("published_at") or x.get("created_at"))
85
+ for rel in day_releases:
86
+ lines.append("----- Release Start -----")
87
+ tag = rel.get("tag_name", "N/A")
88
+ name = rel.get("name", "")
89
+ repo = rel.get("repo", "N/A")
90
+ author = rel.get("author", "N/A")
91
+ draft = rel.get("draft", False)
92
+ prerelease = rel.get("prerelease", False)
93
+ body = rel.get("body", "")
94
+ assets = rel.get("assets", [])
95
+
96
+ status = []
97
+ if draft:
98
+ status.append("draft")
99
+ if prerelease:
100
+ status.append("prerelease")
101
+ status_str = f" ({', '.join(status)})" if status else ""
102
+
103
+ lines.append(f"Release: {name}")
104
+ lines.append(f"Repository: {repo}")
105
+ lines.append(f"Tag: {tag}")
106
+ lines.append(f"Author: {author}")
107
+ if status_str:
108
+ lines.append(f"Status: {status_str.strip()}")
109
+ if body and body.strip():
110
+ lines.append(f"Notes: {body.strip()}")
111
+ if assets:
112
+ lines.append("Assets:")
113
+ for asset in assets:
114
+ asset_name = asset.get("name", "N/A")
115
+ asset_size = asset.get("size", "N/A")
116
+ asset_url = asset.get("download_url", "")
117
+ lines.append(f" - {asset_name} ({asset_size} bytes){' - ' + asset_url if asset_url else ''}")
118
+ lines.append("----- Release End -----\n") # clear end of release
119
+
120
+ lines.append("") # blank line between days
121
+
122
+ return "\n".join(lines)
123
+
124
+
125
+ # Example usage for releases:
126
+ # if __name__ == "__main__":
127
+ # releases = [
128
+ # {
129
+ # "tag_name": "v1.0.0",
130
+ # "name": "Release 1.0.0",
131
+ # "repo": "test-repo",
132
+ # "author": "testuser",
133
+ # "published_at": "2025-03-15T10:00:00",
134
+ # "created_at": "2025-03-15T09:00:00",
135
+ # "draft": False,
136
+ # "prerelease": False,
137
+ # "body": "This is a test release",
138
+ # "assets": [
139
+ # {
140
+ # "name": "test-asset.zip",
141
+ # "size": 1024,
142
+ # "download_url": "https://github.com/test/releases/download/v1.0.0/test-asset.zip",
143
+ # }
144
+ # ],
145
+ # }
146
+ # ]
147
+ # print(parse_releases_to_txt(releases))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-recap
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A modular Python tool that aggregates and formats user-authored messages from repositories.
5
5
  Author: Bruno V.
6
6
  Author-email: bruno.vitorino@tecnico.ulisboa.pt
@@ -21,8 +21,16 @@ Dynamic: license-file
21
21
  Dynamic: requires-dist
22
22
  Dynamic: summary
23
23
 
24
+ <a href="https://www.uneed.best/tool/gitrecap">
25
+ <img src="https://www.uneed.best/POTD2A.png" style="width: 250px;" alt="Uneed POTD2 Badge" />
26
+ </a>
27
+
28
+ ---
29
+
24
30
  # Git Recap
25
31
 
32
+ 🎉 **Featured in Uneed's Latest Newsletter as a Staff Pick!** 🎉
33
+
26
34
  Git Recap is a modular Python tool that aggregates and formats user-authored messages from repositories hosted on GitHub, Azure DevOps, and GitLab. It fetches commit messages, pull requests (along with their associated commits), and issues, then consolidates and sorts these events into a clear, chronological summary. This summary is output as a plain text string that can serve as context for large language models or other analysis tools.
27
35
 
28
36
  ## Features
@@ -0,0 +1,14 @@
1
+ git_recap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ git_recap/fetcher.py,sha256=oRlenzd9OsiBkCtpUgSZGlUoUnVpdkolUZhFOF1USBs,2677
3
+ git_recap/utils.py,sha256=NAG0cvm1sYKEQD5E9PueduB3CZQRTahrioxZNgwwmi4,5481
4
+ git_recap/providers/__init__.py,sha256=njrsu58KB60-wN78P0egm1lkQLIRq_PHLKM4WBt39Os,330
5
+ git_recap/providers/azure_fetcher.py,sha256=ex1XX1SZ5xgWDSEQdsAtaKGaOK21pem_sWdoPZe2DNE,13152
6
+ git_recap/providers/base_fetcher.py,sha256=yqVHTVAPlsDPD85SESx1u6SHeT_I9XLv41SnFo_ESPQ,7472
7
+ git_recap/providers/github_fetcher.py,sha256=pSvpaAoE2-lK-rEwvpliOZh-oIiI0SulHMuiYaqiEts,19239
8
+ git_recap/providers/gitlab_fetcher.py,sha256=i9W00H-DLyc-R--KxWbVy4CSDbORf_BmWcKveWulyzs,9752
9
+ git_recap/providers/url_fetcher.py,sha256=hwASWC8j17P37qLoQxO6WeT1EZ2AqwtYc9kiXKHLBhQ,10753
10
+ git_recap-0.1.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ git_recap-0.1.5.dist-info/METADATA,sha256=CbKCAeuSX9PBSxb8HpYea_OZi8dlzgIpB44Vgur9aNY,4946
12
+ git_recap-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ git_recap-0.1.5.dist-info/top_level.txt,sha256=1JUKd3WPB8c3LcD1deIW-1UTmYzA0zJqwugAz72YZ_o,10
14
+ git_recap-0.1.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (79.0.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,14 +0,0 @@
1
- git_recap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- git_recap/fetcher.py,sha256=oRlenzd9OsiBkCtpUgSZGlUoUnVpdkolUZhFOF1USBs,2677
3
- git_recap/utils.py,sha256=mAGZ6bNklBmE-qpA6RG17_Bka8WRXzjLRAAGS9p3QXk,3646
4
- git_recap/providers/__init__.py,sha256=njrsu58KB60-wN78P0egm1lkQLIRq_PHLKM4WBt39Os,330
5
- git_recap/providers/azure_fetcher.py,sha256=PfPEeWTtx1Fqa1jClFPKqXhY5Yvs8MyEkSJkLAl4QvQ,7241
6
- git_recap/providers/base_fetcher.py,sha256=SS35FP8B9_RwnU6CnMbcV9g5_YRWp7v-S7pwbt402Ng,3067
7
- git_recap/providers/github_fetcher.py,sha256=rDb00RVy2NMpwv8s8Ry1-UjS6wf1Sh2Ndl0xKOZXMxU,4998
8
- git_recap/providers/gitlab_fetcher.py,sha256=ch1V9O-MPv5_nyg9yLs6EC9e-yqsFXBXMm6xVevWzuQ,5396
9
- git_recap/providers/url_fetcher.py,sha256=ofpwpwygJFlW9x7pU2PrO-tn6XcykWDSJbXeBV1KK3c,8337
10
- git_recap-0.1.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
- git_recap-0.1.3.dist-info/METADATA,sha256=-6OhqY14z1qf3BjkUaH6LNGXeAkTy2A_-1gXiwtRRIY,4721
12
- git_recap-0.1.3.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
13
- git_recap-0.1.3.dist-info/top_level.txt,sha256=1JUKd3WPB8c3LcD1deIW-1UTmYzA0zJqwugAz72YZ_o,10
14
- git_recap-0.1.3.dist-info/RECORD,,