framework-m-studio 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ """GitHub Provider for Studio Cloud Mode.
2
+
3
+ Provides GitHub-specific API operations like creating pull requests,
4
+ managing branches, and repository operations using the GitHub REST API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+ from urllib.error import HTTPError
13
+ from urllib.parse import urljoin
14
+ from urllib.request import Request, urlopen
15
+
16
+
17
+ @dataclass
18
+ class PullRequest:
19
+ """GitHub Pull Request data."""
20
+
21
+ number: int
22
+ """PR number."""
23
+
24
+ title: str
25
+ """PR title."""
26
+
27
+ url: str
28
+ """Web URL to the PR."""
29
+
30
+ state: str
31
+ """PR state: open, closed, merged."""
32
+
33
+ head_branch: str
34
+ """Source branch name."""
35
+
36
+ base_branch: str
37
+ """Target branch name."""
38
+
39
+
40
+ @dataclass
41
+ class Repository:
42
+ """GitHub Repository data."""
43
+
44
+ full_name: str
45
+ """Full repo name (owner/repo)."""
46
+
47
+ default_branch: str
48
+ """Default branch name."""
49
+
50
+ clone_url: str
51
+ """HTTPS clone URL."""
52
+
53
+ private: bool
54
+ """Whether the repo is private."""
55
+
56
+
57
+ class GitHubError(Exception):
58
+ """GitHub API error."""
59
+
60
+ def __init__(self, message: str, status_code: int = 0):
61
+ super().__init__(message)
62
+ self.status_code = status_code
63
+
64
+
65
+ class GitHubAuthError(GitHubError):
66
+ """Authentication failed."""
67
+
68
+ pass
69
+
70
+
71
+ class GitHubProvider:
72
+ """GitHub API provider.
73
+
74
+ Provides GitHub-specific operations using the REST API.
75
+ Uses personal access tokens for authentication.
76
+ """
77
+
78
+ API_BASE = "https://api.github.com"
79
+
80
+ def __init__(self, token: str):
81
+ """Initialize GitHubProvider.
82
+
83
+ Args:
84
+ token: GitHub personal access token.
85
+ """
86
+ self._token = token
87
+
88
+ def _request(
89
+ self,
90
+ method: str,
91
+ endpoint: str,
92
+ data: dict[str, Any] | None = None,
93
+ ) -> dict[str, Any]:
94
+ """Make an authenticated request to GitHub API.
95
+
96
+ Args:
97
+ method: HTTP method (GET, POST, PATCH, DELETE).
98
+ endpoint: API endpoint (e.g., /repos/owner/repo).
99
+ data: Request body data.
100
+
101
+ Returns:
102
+ Response JSON as dict.
103
+
104
+ Raises:
105
+ GitHubError: If request fails.
106
+ """
107
+ url = urljoin(self.API_BASE, endpoint)
108
+ headers = {
109
+ "Authorization": f"Bearer {self._token}",
110
+ "Accept": "application/vnd.github+json",
111
+ "X-GitHub-Api-Version": "2022-11-28",
112
+ }
113
+
114
+ body = None
115
+ if data:
116
+ body = json.dumps(data).encode("utf-8")
117
+ headers["Content-Type"] = "application/json"
118
+
119
+ request = Request(url, data=body, headers=headers, method=method)
120
+
121
+ try:
122
+ with urlopen(request, timeout=30) as response:
123
+ response_body = response.read().decode("utf-8")
124
+ if response_body:
125
+ result: dict[str, Any] = json.loads(response_body)
126
+ return result
127
+ return {}
128
+ except HTTPError as e:
129
+ error_body = e.read().decode("utf-8") if e.fp else ""
130
+ if e.code == 401:
131
+ raise GitHubAuthError(
132
+ f"Authentication failed: {error_body}", e.code
133
+ ) from e
134
+ raise GitHubError(f"GitHub API error: {error_body}", e.code) from e
135
+
136
+ def get_repository(self, owner: str, repo: str) -> Repository:
137
+ """Get repository information.
138
+
139
+ Args:
140
+ owner: Repository owner (user or org).
141
+ repo: Repository name.
142
+
143
+ Returns:
144
+ Repository data.
145
+ """
146
+ data = self._request("GET", f"/repos/{owner}/{repo}")
147
+ return Repository(
148
+ full_name=data["full_name"],
149
+ default_branch=data["default_branch"],
150
+ clone_url=data["clone_url"],
151
+ private=data["private"],
152
+ )
153
+
154
+ def create_branch(
155
+ self,
156
+ owner: str,
157
+ repo: str,
158
+ branch_name: str,
159
+ from_ref: str = "HEAD",
160
+ ) -> str:
161
+ """Create a new branch.
162
+
163
+ Args:
164
+ owner: Repository owner.
165
+ repo: Repository name.
166
+ branch_name: New branch name.
167
+ from_ref: Base reference (branch, tag, or SHA).
168
+
169
+ Returns:
170
+ The created ref name.
171
+ """
172
+ # Get the SHA of the base ref
173
+ ref_data = self._request(
174
+ "GET", f"/repos/{owner}/{repo}/git/ref/heads/{from_ref}"
175
+ )
176
+ sha = ref_data["object"]["sha"]
177
+
178
+ # Create new branch
179
+ self._request(
180
+ "POST",
181
+ f"/repos/{owner}/{repo}/git/refs",
182
+ data={"ref": f"refs/heads/{branch_name}", "sha": sha},
183
+ )
184
+ return f"refs/heads/{branch_name}"
185
+
186
+ def create_pull_request(
187
+ self,
188
+ owner: str,
189
+ repo: str,
190
+ title: str,
191
+ head: str,
192
+ base: str,
193
+ body: str = "",
194
+ draft: bool = False,
195
+ ) -> PullRequest:
196
+ """Create a pull request.
197
+
198
+ Args:
199
+ owner: Repository owner.
200
+ repo: Repository name.
201
+ title: PR title.
202
+ head: Source branch.
203
+ base: Target branch.
204
+ body: PR description.
205
+ draft: Create as draft PR.
206
+
207
+ Returns:
208
+ Created PullRequest.
209
+ """
210
+ data = self._request(
211
+ "POST",
212
+ f"/repos/{owner}/{repo}/pulls",
213
+ data={
214
+ "title": title,
215
+ "head": head,
216
+ "base": base,
217
+ "body": body,
218
+ "draft": draft,
219
+ },
220
+ )
221
+ return PullRequest(
222
+ number=data["number"],
223
+ title=data["title"],
224
+ url=data["html_url"],
225
+ state=data["state"],
226
+ head_branch=data["head"]["ref"],
227
+ base_branch=data["base"]["ref"],
228
+ )
229
+
230
+ def get_pull_request(
231
+ self,
232
+ owner: str,
233
+ repo: str,
234
+ pr_number: int,
235
+ ) -> PullRequest:
236
+ """Get a pull request.
237
+
238
+ Args:
239
+ owner: Repository owner.
240
+ repo: Repository name.
241
+ pr_number: PR number.
242
+
243
+ Returns:
244
+ PullRequest data.
245
+ """
246
+ data = self._request("GET", f"/repos/{owner}/{repo}/pulls/{pr_number}")
247
+ return PullRequest(
248
+ number=data["number"],
249
+ title=data["title"],
250
+ url=data["html_url"],
251
+ state=data["state"],
252
+ head_branch=data["head"]["ref"],
253
+ base_branch=data["base"]["ref"],
254
+ )
255
+
256
+ def list_pull_requests(
257
+ self,
258
+ owner: str,
259
+ repo: str,
260
+ state: str = "open",
261
+ head: str | None = None,
262
+ ) -> list[PullRequest]:
263
+ """List pull requests.
264
+
265
+ Args:
266
+ owner: Repository owner.
267
+ repo: Repository name.
268
+ state: Filter by state (open, closed, all).
269
+ head: Filter by head branch (owner:branch).
270
+
271
+ Returns:
272
+ List of PullRequests.
273
+ """
274
+ endpoint = f"/repos/{owner}/{repo}/pulls?state={state}"
275
+ if head:
276
+ endpoint += f"&head={head}"
277
+
278
+ data = self._request("GET", endpoint)
279
+ # data is a list of dicts, but mypy sees it as dict[str, Any]
280
+ if not isinstance(data, list):
281
+ return []
282
+ return [
283
+ PullRequest(
284
+ number=int(pr["number"])
285
+ if isinstance(pr.get("number"), (int, str))
286
+ else 0,
287
+ title=str(pr["title"]) if "title" in pr else "",
288
+ url=str(pr["html_url"]) if "html_url" in pr else "",
289
+ state=str(pr["state"]) if "state" in pr else "",
290
+ head_branch=str(pr["head"]["ref"])
291
+ if "head" in pr and "ref" in pr["head"]
292
+ else "",
293
+ base_branch=str(pr["base"]["ref"])
294
+ if "base" in pr and "ref" in pr["base"]
295
+ else "",
296
+ )
297
+ for pr in data
298
+ ]
299
+
300
+ def get_authenticated_user(self) -> dict[str, Any]:
301
+ """Get the authenticated user.
302
+
303
+ Returns:
304
+ User data including login, name, email.
305
+ """
306
+ return self._request("GET", "/user")
307
+
308
+ def validate_token(self) -> bool:
309
+ """Validate the access token.
310
+
311
+ Returns:
312
+ True if token is valid.
313
+
314
+ Raises:
315
+ GitHubAuthError: If token is invalid.
316
+ """
317
+ try:
318
+ self.get_authenticated_user()
319
+ return True
320
+ except GitHubAuthError:
321
+ return False
@@ -0,0 +1,249 @@
1
+ """Git Adapter Protocol.
2
+
3
+ Defines the interface for Git operations following Hexagonal Architecture.
4
+ The core logic depends on this protocol, not on specific implementations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+
14
+ @dataclass
15
+ class GitStatus:
16
+ """Status of a Git workspace."""
17
+
18
+ branch: str
19
+ """Current branch name."""
20
+
21
+ is_clean: bool
22
+ """True if working directory has no changes."""
23
+
24
+ modified_files: list[str] = field(default_factory=list)
25
+ """List of modified file paths relative to workspace root."""
26
+
27
+ staged_files: list[str] = field(default_factory=list)
28
+ """List of staged file paths."""
29
+
30
+ untracked_files: list[str] = field(default_factory=list)
31
+ """List of untracked file paths."""
32
+
33
+ ahead: int = 0
34
+ """Number of commits ahead of remote."""
35
+
36
+ behind: int = 0
37
+ """Number of commits behind remote."""
38
+
39
+
40
+ @dataclass
41
+ class CommitResult:
42
+ """Result of a commit operation."""
43
+
44
+ sha: str
45
+ """The commit SHA."""
46
+
47
+ message: str
48
+ """The commit message."""
49
+
50
+ files_changed: int
51
+ """Number of files changed in this commit."""
52
+
53
+
54
+ class GitAdapterProtocol(Protocol):
55
+ """Port for Git operations.
56
+
57
+ Implementations can use git CLI, dulwich, gitpython, etc.
58
+ This protocol abstracts the Git implementation details.
59
+ """
60
+
61
+ async def clone(
62
+ self,
63
+ repo_url: str,
64
+ target_dir: Path,
65
+ *,
66
+ auth_token: str | None = None,
67
+ branch: str | None = None,
68
+ ) -> None:
69
+ """Clone a repository to the target directory.
70
+
71
+ Args:
72
+ repo_url: The repository URL (HTTPS or SSH).
73
+ target_dir: Local path to clone into.
74
+ auth_token: Personal access token for HTTPS auth.
75
+ branch: Specific branch to clone (default: default branch).
76
+
77
+ Raises:
78
+ GitError: If clone fails (auth, network, etc.).
79
+ """
80
+ ...
81
+
82
+ async def commit(
83
+ self,
84
+ workspace: Path,
85
+ message: str,
86
+ *,
87
+ author: str | None = None,
88
+ ) -> CommitResult:
89
+ """Stage all changes and create a commit.
90
+
91
+ Args:
92
+ workspace: Path to the Git workspace.
93
+ message: Commit message.
94
+ author: Author string (default: from git config).
95
+
96
+ Returns:
97
+ CommitResult with SHA and metadata.
98
+
99
+ Raises:
100
+ GitError: If commit fails or no changes to commit.
101
+ """
102
+ ...
103
+
104
+ async def push(
105
+ self,
106
+ workspace: Path,
107
+ branch: str | None = None,
108
+ *,
109
+ force: bool = False,
110
+ ) -> None:
111
+ """Push commits to remote.
112
+
113
+ Args:
114
+ workspace: Path to the Git workspace.
115
+ branch: Branch to push (default: current branch).
116
+ force: Force push (use with caution).
117
+
118
+ Raises:
119
+ GitError: If push fails (auth, conflicts, etc.).
120
+ """
121
+ ...
122
+
123
+ async def pull(
124
+ self,
125
+ workspace: Path,
126
+ *,
127
+ rebase: bool = True,
128
+ ) -> None:
129
+ """Pull latest changes from remote.
130
+
131
+ Args:
132
+ workspace: Path to the Git workspace.
133
+ rebase: If True, use --rebase (default).
134
+
135
+ Raises:
136
+ GitError: If pull fails (conflicts, etc.).
137
+ """
138
+ ...
139
+
140
+ async def create_branch(
141
+ self,
142
+ workspace: Path,
143
+ name: str,
144
+ *,
145
+ checkout: bool = True,
146
+ ) -> None:
147
+ """Create a new branch.
148
+
149
+ Args:
150
+ workspace: Path to the Git workspace.
151
+ name: Branch name.
152
+ checkout: If True, switch to the new branch.
153
+
154
+ Raises:
155
+ GitError: If branch creation fails.
156
+ """
157
+ ...
158
+
159
+ async def checkout(
160
+ self,
161
+ workspace: Path,
162
+ ref: str,
163
+ ) -> None:
164
+ """Checkout a branch or commit.
165
+
166
+ Args:
167
+ workspace: Path to the Git workspace.
168
+ ref: Branch name, tag, or commit SHA.
169
+
170
+ Raises:
171
+ GitError: If checkout fails.
172
+ """
173
+ ...
174
+
175
+ async def get_status(
176
+ self,
177
+ workspace: Path,
178
+ ) -> GitStatus:
179
+ """Get the current status of the workspace.
180
+
181
+ Args:
182
+ workspace: Path to the Git workspace.
183
+
184
+ Returns:
185
+ GitStatus with branch info and file changes.
186
+ """
187
+ ...
188
+
189
+ async def get_current_branch(
190
+ self,
191
+ workspace: Path,
192
+ ) -> str:
193
+ """Get the name of the current branch.
194
+
195
+ Args:
196
+ workspace: Path to the Git workspace.
197
+
198
+ Returns:
199
+ Branch name.
200
+ """
201
+ ...
202
+
203
+ async def fetch(
204
+ self,
205
+ workspace: Path,
206
+ ) -> None:
207
+ """Fetch updates from remote without merging.
208
+
209
+ Used to check for available updates (Updates Available indicator).
210
+
211
+ Args:
212
+ workspace: Path to the Git workspace.
213
+
214
+ Raises:
215
+ GitError: If fetch fails (auth, network, etc.).
216
+ """
217
+ ...
218
+
219
+
220
+ class GitError(Exception):
221
+ """Base exception for Git operations."""
222
+
223
+ def __init__(self, message: str, returncode: int = 1):
224
+ """Initialize GitError.
225
+
226
+ Args:
227
+ message: Error description.
228
+ returncode: Git command exit code.
229
+ """
230
+ super().__init__(message)
231
+ self.returncode = returncode
232
+
233
+
234
+ class GitAuthError(GitError):
235
+ """Authentication failed."""
236
+
237
+ pass
238
+
239
+
240
+ class GitConflictError(GitError):
241
+ """Merge/rebase conflict occurred."""
242
+
243
+ pass
244
+
245
+
246
+ class GitNetworkError(GitError):
247
+ """Network-related error (timeout, DNS, etc.)."""
248
+
249
+ pass
File without changes