git-recap 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {git_recap-0.1.4 → git_recap-0.1.5}/PKG-INFO +1 -1
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/providers/azure_fetcher.py +79 -2
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/providers/base_fetcher.py +66 -0
- git_recap-0.1.5/git_recap/providers/github_fetcher.py +408 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/providers/gitlab_fetcher.py +65 -3
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/providers/url_fetcher.py +59 -2
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap.egg-info/PKG-INFO +1 -1
- {git_recap-0.1.4 → git_recap-0.1.5}/setup.py +1 -1
- {git_recap-0.1.4 → git_recap-0.1.5}/tests/test_dummy_parser.py +10 -1
- git_recap-0.1.5/tests/test_parser.py +630 -0
- git_recap-0.1.4/git_recap/providers/github_fetcher.py +0 -177
- git_recap-0.1.4/tests/test_parser.py +0 -237
- {git_recap-0.1.4 → git_recap-0.1.5}/LICENSE +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/README.md +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/__init__.py +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/fetcher.py +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/providers/__init__.py +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap/utils.py +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap.egg-info/SOURCES.txt +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap.egg-info/dependency_links.txt +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap.egg-info/requires.txt +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/git_recap.egg-info/top_level.txt +0 -0
- {git_recap-0.1.4 → git_recap-0.1.5}/setup.cfg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
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):
|
|
@@ -236,4 +236,81 @@ class AzureFetcher(BaseFetcher):
|
|
|
236
236
|
NotImplementedError: Always, since release fetching is not supported for AzureFetcher.
|
|
237
237
|
"""
|
|
238
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).")
|
|
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).")
|
|
@@ -90,6 +90,72 @@ class BaseFetcher(ABC):
|
|
|
90
90
|
"""
|
|
91
91
|
raise NotImplementedError("Release fetching is not implemented for this provider.")
|
|
92
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
|
+
|
|
93
159
|
def get_authored_messages(self) -> List[Dict[str, Any]]:
|
|
94
160
|
"""
|
|
95
161
|
Aggregates all commit, pull request, and issue entries into a single list,
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from github import Github
|
|
2
|
+
from github import GithubException
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from git_recap.providers.base_fetcher import BaseFetcher
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitHubFetcher(BaseFetcher):
|
|
12
|
+
"""
|
|
13
|
+
Fetcher implementation for GitHub repositories.
|
|
14
|
+
|
|
15
|
+
Supports fetching commits, pull requests, issues, and releases.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, pat: str, start_date=None, end_date=None, repo_filter=None, authors=None):
|
|
19
|
+
super().__init__(pat, start_date, end_date, repo_filter, authors)
|
|
20
|
+
self.github = Github(self.pat)
|
|
21
|
+
self.user = self.github.get_user()
|
|
22
|
+
self.repos = self.user.get_repos(affiliation="owner,collaborator,organization_member")
|
|
23
|
+
self.authors.append(self.user.login)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def repos_names(self) -> List[str]:
|
|
27
|
+
return [repo.name for repo in self.repos]
|
|
28
|
+
|
|
29
|
+
def _stop_fetching(self, date_obj: datetime) -> bool:
|
|
30
|
+
if self.start_date and date_obj < self.start_date:
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def _filter_by_date(self, date_obj: datetime) -> bool:
|
|
35
|
+
if self.start_date and date_obj < self.start_date:
|
|
36
|
+
return False
|
|
37
|
+
if self.end_date and date_obj > self.end_date:
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def fetch_commits(self) -> List[Dict[str, Any]]:
|
|
42
|
+
entries = []
|
|
43
|
+
processed_commits = set()
|
|
44
|
+
for repo in self.repos:
|
|
45
|
+
if self.repo_filter and repo.name not in self.repo_filter:
|
|
46
|
+
continue
|
|
47
|
+
for author in self.authors:
|
|
48
|
+
commits = repo.get_commits(author=author)
|
|
49
|
+
for i, commit in enumerate(commits, start=1):
|
|
50
|
+
commit_date = commit.commit.author.date
|
|
51
|
+
if self._filter_by_date(commit_date):
|
|
52
|
+
sha = commit.sha
|
|
53
|
+
if sha not in processed_commits:
|
|
54
|
+
entry = {
|
|
55
|
+
"type": "commit",
|
|
56
|
+
"repo": repo.name,
|
|
57
|
+
"message": commit.commit.message.strip(),
|
|
58
|
+
"timestamp": commit_date,
|
|
59
|
+
"sha": sha,
|
|
60
|
+
}
|
|
61
|
+
entries.append(entry)
|
|
62
|
+
processed_commits.add(sha)
|
|
63
|
+
if self._stop_fetching(commit_date):
|
|
64
|
+
break
|
|
65
|
+
return entries
|
|
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
|
+
|
|
93
|
+
def fetch_pull_requests(self) -> List[Dict[str, Any]]:
|
|
94
|
+
entries = []
|
|
95
|
+
# Maintain a local set to skip duplicate commits already captured in a PR.
|
|
96
|
+
processed_pr_commits = set()
|
|
97
|
+
# Retrieve repos where you're owner, a collaborator, or an organization member.
|
|
98
|
+
for repo in self.repos:
|
|
99
|
+
if self.repo_filter and repo.name not in self.repo_filter:
|
|
100
|
+
continue
|
|
101
|
+
pulls = repo.get_pulls(state='all')
|
|
102
|
+
for i, pr in enumerate(pulls, start=1):
|
|
103
|
+
if pr.user.login not in self.authors:
|
|
104
|
+
continue
|
|
105
|
+
pr_date = pr.updated_at # alternatively, use pr.created_at
|
|
106
|
+
if not self._filter_by_date(pr_date):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Add the pull request itself.
|
|
110
|
+
pr_entry = {
|
|
111
|
+
"type": "pull_request",
|
|
112
|
+
"repo": repo.name,
|
|
113
|
+
"message": pr.title,
|
|
114
|
+
"timestamp": pr_date,
|
|
115
|
+
"pr_number": pr.number,
|
|
116
|
+
}
|
|
117
|
+
entries.append(pr_entry)
|
|
118
|
+
|
|
119
|
+
# Now, add commits associated with this pull request.
|
|
120
|
+
pr_commits = pr.get_commits()
|
|
121
|
+
for pr_commit in pr_commits:
|
|
122
|
+
commit_date = pr_commit.commit.author.date
|
|
123
|
+
if self._filter_by_date(commit_date):
|
|
124
|
+
sha = pr_commit.sha
|
|
125
|
+
if sha in processed_pr_commits:
|
|
126
|
+
continue
|
|
127
|
+
pr_commit_entry = {
|
|
128
|
+
"type": "commit_from_pr",
|
|
129
|
+
"repo": repo.name,
|
|
130
|
+
"message": pr_commit.commit.message.strip(),
|
|
131
|
+
"timestamp": commit_date,
|
|
132
|
+
"sha": sha,
|
|
133
|
+
"pr_title": pr.title,
|
|
134
|
+
}
|
|
135
|
+
entries.append(pr_commit_entry)
|
|
136
|
+
processed_pr_commits.add(sha)
|
|
137
|
+
if self._stop_fetching(pr_date):
|
|
138
|
+
break
|
|
139
|
+
return entries
|
|
140
|
+
|
|
141
|
+
def fetch_issues(self) -> List[Dict[str, Any]]:
|
|
142
|
+
entries = []
|
|
143
|
+
issues = self.user.get_issues()
|
|
144
|
+
for i, issue in enumerate(issues, start=1):
|
|
145
|
+
issue_date = issue.created_at
|
|
146
|
+
if self._filter_by_date(issue_date):
|
|
147
|
+
entry = {
|
|
148
|
+
"type": "issue",
|
|
149
|
+
"repo": issue.repository.name,
|
|
150
|
+
"message": issue.title,
|
|
151
|
+
"timestamp": issue_date,
|
|
152
|
+
}
|
|
153
|
+
entries.append(entry)
|
|
154
|
+
if self._stop_fetching(issue_date):
|
|
155
|
+
break
|
|
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,6 +1,6 @@
|
|
|
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):
|
|
@@ -183,5 +183,67 @@ class GitLabFetcher(BaseFetcher):
|
|
|
183
183
|
Raises:
|
|
184
184
|
NotImplementedError: Always, since release fetching is not supported for GitLabFetcher.
|
|
185
185
|
"""
|
|
186
|
-
|
|
187
|
-
|
|
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).")
|