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.
- git_recap/providers/azure_fetcher.py +158 -5
- git_recap/providers/base_fetcher.py +130 -8
- git_recap/providers/github_fetcher.py +293 -5
- git_recap/providers/gitlab_fetcher.py +126 -6
- git_recap/providers/url_fetcher.py +81 -13
- git_recap/utils.py +91 -37
- {git_recap-0.1.3.dist-info → git_recap-0.1.5.dist-info}/METADATA +9 -1
- git_recap-0.1.5.dist-info/RECORD +14 -0
- {git_recap-0.1.3.dist-info → git_recap-0.1.5.dist-info}/WHEEL +1 -1
- git_recap-0.1.3.dist-info/RECORD +0 -14
- {git_recap-0.1.3.dist-info → git_recap-0.1.5.dist-info}/licenses/LICENSE +0 -0
- {git_recap-0.1.3.dist-info → git_recap-0.1.5.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
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,,
|
git_recap-0.1.3.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|