git-alternative 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,91 @@
1
+ """Label resource operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from git_alternative.models import Label, LabelCreate
8
+
9
+
10
+ class LabelsResource:
11
+ """Operations on Forge repository labels.
12
+
13
+ Accessed via :attr:`ForgeClient.labels`.
14
+ """
15
+
16
+ def __init__(self, http: Any) -> None:
17
+ self._http = http
18
+
19
+ def list(self, owner: str, repo: str) -> list[Label]:
20
+ """List all labels in a repository.
21
+
22
+ Args:
23
+ owner: The repository owner's username.
24
+ repo: The repository name.
25
+
26
+ Returns:
27
+ A list of :class:`~git_alternative.models.Label` objects.
28
+ """
29
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/labels")
30
+ return [Label(**item) for item in data]
31
+
32
+ def create(
33
+ self,
34
+ owner: str,
35
+ repo: str,
36
+ name: str,
37
+ color: str,
38
+ description: str | None = None,
39
+ ) -> Label:
40
+ """Create a new label.
41
+
42
+ Args:
43
+ owner: The repository owner's username.
44
+ repo: The repository name.
45
+ name: The label name.
46
+ color: The label color as a hex string (e.g., ``"#d73a4a"``).
47
+ description: Optional description.
48
+
49
+ Returns:
50
+ The newly created :class:`~git_alternative.models.Label`.
51
+ """
52
+ payload = LabelCreate(name=name, color=color, description=description)
53
+ data = self._http.post(
54
+ f"/api/v1/repos/{owner}/{repo}/labels",
55
+ json=payload.model_dump(exclude_none=True),
56
+ )
57
+ return Label(**data)
58
+
59
+ def update(
60
+ self,
61
+ owner: str,
62
+ repo: str,
63
+ label_id: str,
64
+ **kwargs: Any,
65
+ ) -> Label:
66
+ """Update an existing label.
67
+
68
+ Args:
69
+ owner: The repository owner's username.
70
+ repo: The repository name.
71
+ label_id: The label UUID.
72
+ **kwargs: Fields to update (name, color, description).
73
+
74
+ Returns:
75
+ The updated :class:`~git_alternative.models.Label`.
76
+ """
77
+ data = self._http.patch(
78
+ f"/api/v1/repos/{owner}/{repo}/labels/{label_id}",
79
+ json=kwargs,
80
+ )
81
+ return Label(**data)
82
+
83
+ def delete(self, owner: str, repo: str, label_id: str) -> None:
84
+ """Delete a label.
85
+
86
+ Args:
87
+ owner: The repository owner's username.
88
+ repo: The repository name.
89
+ label_id: The label UUID.
90
+ """
91
+ self._http.delete(f"/api/v1/repos/{owner}/{repo}/labels/{label_id}")
@@ -0,0 +1,118 @@
1
+ """CI pipelines resource client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from git_alternative.http import HttpClient
6
+ from git_alternative.models import Job, Pipeline, PipelineState
7
+
8
+
9
+ class PipelinesResource:
10
+ """Client for CI pipeline API endpoints.
11
+
12
+ Args:
13
+ http: Shared HTTP client instance.
14
+ """
15
+
16
+ def __init__(self, http: HttpClient) -> None:
17
+ self._http = http
18
+
19
+ def list(self, owner: str, repo: str, branch: str | None = None) -> list[Pipeline]:
20
+ """List CI pipelines for a repository.
21
+
22
+ Args:
23
+ owner: Repository owner.
24
+ repo: Repository name.
25
+ branch: Filter by branch name (optional).
26
+
27
+ Returns:
28
+ List of :class:`~git_alternative.models.Pipeline` instances.
29
+ """
30
+ params = {}
31
+ if branch:
32
+ params["branch"] = branch
33
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pipelines", params=params or None)
34
+ return [Pipeline(**p) for p in data]
35
+
36
+ def get(self, owner: str, repo: str, pipeline_id: str) -> Pipeline:
37
+ """Fetch a single pipeline by ID.
38
+
39
+ Args:
40
+ owner: Repository owner.
41
+ repo: Repository name.
42
+ pipeline_id: Pipeline UUID string.
43
+
44
+ Returns:
45
+ A :class:`~git_alternative.models.Pipeline` instance.
46
+
47
+ Raises:
48
+ ForgeNotFoundError: If the pipeline does not exist.
49
+ """
50
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pipelines/{pipeline_id}")
51
+ return Pipeline(**data)
52
+
53
+ def cancel(self, owner: str, repo: str, pipeline_id: str) -> Pipeline:
54
+ """Cancel a running pipeline.
55
+
56
+ Args:
57
+ owner: Repository owner.
58
+ repo: Repository name.
59
+ pipeline_id: Pipeline UUID string.
60
+
61
+ Returns:
62
+ The updated :class:`~git_alternative.models.Pipeline` with
63
+ state :attr:`PipelineState.CANCELLED`.
64
+ """
65
+ data = self._http.post(
66
+ f"/api/v1/repos/{owner}/{repo}/pipelines/{pipeline_id}/cancel"
67
+ )
68
+ return Pipeline(**data)
69
+
70
+ def list_jobs(self, owner: str, repo: str, pipeline_id: str) -> list[Job]:
71
+ """List jobs in a pipeline.
72
+
73
+ Args:
74
+ owner: Repository owner.
75
+ repo: Repository name.
76
+ pipeline_id: Pipeline UUID string.
77
+
78
+ Returns:
79
+ List of :class:`~git_alternative.models.Job` instances.
80
+ """
81
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pipelines/{pipeline_id}/jobs")
82
+ return [Job(**j) for j in data]
83
+
84
+ def get_job_logs(
85
+ self, owner: str, repo: str, pipeline_id: str, job_id: str
86
+ ) -> str:
87
+ """Fetch the log output for a pipeline job.
88
+
89
+ Args:
90
+ owner: Repository owner.
91
+ repo: Repository name.
92
+ pipeline_id: Pipeline UUID string.
93
+ job_id: Job UUID string.
94
+
95
+ Returns:
96
+ Log output as a plain text string.
97
+ """
98
+ data = self._http.get(
99
+ f"/api/v1/repos/{owner}/{repo}/pipelines/{pipeline_id}/jobs/{job_id}/logs"
100
+ )
101
+ if isinstance(data, dict):
102
+ return data.get("logs", "")
103
+ return str(data)
104
+
105
+ def get_commit_status(self, owner: str, repo: str, sha: str) -> PipelineState:
106
+ """Get the aggregate CI status for a commit.
107
+
108
+ Args:
109
+ owner: Repository owner.
110
+ repo: Repository name.
111
+ sha: Commit SHA.
112
+
113
+ Returns:
114
+ The :class:`~git_alternative.models.PipelineState` for the commit.
115
+ """
116
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/commits/{sha}/status")
117
+ state_str = data.get("state", "pending") if isinstance(data, dict) else str(data)
118
+ return PipelineState(state_str)
@@ -0,0 +1,207 @@
1
+ """Pull requests resource client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from git_alternative.http import HttpClient
8
+ from git_alternative.models import Commit, MergeResult, MergeStrategy, PaginatedResponse, PrState, PullRequest
9
+
10
+
11
+ class PullsResource:
12
+ """Client for pull request API endpoints.
13
+
14
+ Args:
15
+ http: Shared HTTP client instance.
16
+ """
17
+
18
+ def __init__(self, http: HttpClient) -> None:
19
+ self._http = http
20
+
21
+ def list(
22
+ self,
23
+ owner: str,
24
+ repo: str,
25
+ state: str = "open",
26
+ page: int = 1,
27
+ per_page: int = 30,
28
+ ) -> PaginatedResponse[PullRequest]:
29
+ """List pull requests in a repository.
30
+
31
+ Args:
32
+ owner: Repository owner.
33
+ repo: Repository name.
34
+ state: Filter by state — ``"open"``, ``"closed"``, ``"merged"``,
35
+ or ``"all"`` (default: ``"open"``).
36
+ page: Page number (1-based, default: 1).
37
+ per_page: Items per page (max: 100, default: 30).
38
+
39
+ Returns:
40
+ A :class:`~git_alternative.models.PaginatedResponse` of
41
+ :class:`~git_alternative.models.PullRequest` instances.
42
+ """
43
+ params: dict[str, Any] = {"state": state, "page": page, "per_page": per_page}
44
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pulls", params=params)
45
+ items = data.get("items", data if isinstance(data, list) else [])
46
+ prs = [PullRequest(**p) for p in items]
47
+ if isinstance(data, dict):
48
+ return PaginatedResponse[PullRequest](
49
+ items=prs,
50
+ page=data.get("page", page),
51
+ per_page=data.get("per_page", per_page),
52
+ has_next_page=data.get("has_next_page", False),
53
+ total_count=data.get("total_count"),
54
+ )
55
+ return PaginatedResponse[PullRequest](
56
+ items=prs,
57
+ page=page,
58
+ per_page=per_page,
59
+ has_next_page=False,
60
+ )
61
+
62
+ def get(self, owner: str, repo: str, number: int) -> PullRequest:
63
+ """Fetch a single pull request by number.
64
+
65
+ Args:
66
+ owner: Repository owner.
67
+ repo: Repository name.
68
+ number: Pull request number.
69
+
70
+ Returns:
71
+ A :class:`~git_alternative.models.PullRequest` instance.
72
+
73
+ Raises:
74
+ ForgeNotFoundError: If the PR does not exist.
75
+ """
76
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pulls/{number}")
77
+ return PullRequest(**data)
78
+
79
+ def create(
80
+ self,
81
+ owner: str,
82
+ repo: str,
83
+ title: str,
84
+ head_branch: str,
85
+ base_branch: str,
86
+ body: str | None = None,
87
+ draft: bool = False,
88
+ reviewers: list[str] | None = None,
89
+ ) -> PullRequest:
90
+ """Open a new pull request.
91
+
92
+ Args:
93
+ owner: Repository owner.
94
+ repo: Repository name.
95
+ title: PR title.
96
+ head_branch: Source branch (may be ``owner:branch`` for cross-fork PRs).
97
+ base_branch: Target branch.
98
+ body: Optional Markdown description.
99
+ draft: Whether to open as a draft (default: False).
100
+ reviewers: Optional list of reviewer usernames.
101
+
102
+ Returns:
103
+ The newly created :class:`~git_alternative.models.PullRequest`.
104
+ """
105
+ payload: dict[str, Any] = {
106
+ "title": title,
107
+ "head_branch": head_branch,
108
+ "base_branch": base_branch,
109
+ "draft": draft,
110
+ }
111
+ if body is not None:
112
+ payload["body"] = body
113
+ if reviewers:
114
+ payload["reviewers"] = reviewers
115
+ data = self._http.post(f"/api/v1/repos/{owner}/{repo}/pulls", json=payload)
116
+ return PullRequest(**data)
117
+
118
+ def update(
119
+ self,
120
+ owner: str,
121
+ repo: str,
122
+ number: int,
123
+ title: str | None = None,
124
+ body: str | None = None,
125
+ state: PrState | None = None,
126
+ ) -> PullRequest:
127
+ """Update a pull request.
128
+
129
+ Args:
130
+ owner: Repository owner.
131
+ repo: Repository name.
132
+ number: PR number.
133
+ title: New title (omit to leave unchanged).
134
+ body: New body (omit to leave unchanged).
135
+ state: New state (omit to leave unchanged).
136
+
137
+ Returns:
138
+ The updated :class:`~git_alternative.models.PullRequest`.
139
+ """
140
+ payload: dict[str, Any] = {}
141
+ if title is not None:
142
+ payload["title"] = title
143
+ if body is not None:
144
+ payload["body"] = body
145
+ if state is not None:
146
+ payload["state"] = state.value
147
+ data = self._http.patch(f"/api/v1/repos/{owner}/{repo}/pulls/{number}", json=payload)
148
+ return PullRequest(**data)
149
+
150
+ def merge(
151
+ self,
152
+ owner: str,
153
+ repo: str,
154
+ number: int,
155
+ strategy: MergeStrategy = MergeStrategy.MERGE,
156
+ commit_message: str | None = None,
157
+ ) -> MergeResult:
158
+ """Merge a pull request.
159
+
160
+ Args:
161
+ owner: Repository owner.
162
+ repo: Repository name.
163
+ number: PR number.
164
+ strategy: Merge strategy (default: :attr:`MergeStrategy.MERGE`).
165
+ commit_message: Optional custom commit message.
166
+
167
+ Returns:
168
+ A :class:`~git_alternative.models.MergeResult` instance.
169
+
170
+ Raises:
171
+ ForgeConflictError: If the PR has merge conflicts.
172
+ """
173
+ payload: dict[str, Any] = {"strategy": strategy.value}
174
+ if commit_message is not None:
175
+ payload["commit_message"] = commit_message
176
+ data = self._http.post(
177
+ f"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", json=payload
178
+ )
179
+ return MergeResult(**data)
180
+
181
+ def list_commits(self, owner: str, repo: str, number: int) -> list[Commit]:
182
+ """List commits in a pull request.
183
+
184
+ Args:
185
+ owner: Repository owner.
186
+ repo: Repository name.
187
+ number: PR number.
188
+
189
+ Returns:
190
+ List of :class:`~git_alternative.models.Commit` instances.
191
+ """
192
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/pulls/{number}/commits")
193
+ return [Commit(**c) for c in data]
194
+
195
+ def list_files(self, owner: str, repo: str, number: int) -> list[dict[str, Any]]:
196
+ """List files changed in a pull request.
197
+
198
+ Args:
199
+ owner: Repository owner.
200
+ repo: Repository name.
201
+ number: PR number.
202
+
203
+ Returns:
204
+ List of file diff dictionaries with keys ``filename``,
205
+ ``additions``, ``deletions``, ``status``.
206
+ """
207
+ return self._http.get(f"/api/v1/repos/{owner}/{repo}/pulls/{number}/files")
@@ -0,0 +1,184 @@
1
+ """Repository resource client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from git_alternative.http import HttpClient
8
+ from git_alternative.models import Branch, Commit, PaginatedResponse, Repository
9
+
10
+
11
+ class ReposResource:
12
+ """Client for repository-related API endpoints.
13
+
14
+ Args:
15
+ http: Shared HTTP client instance.
16
+ """
17
+
18
+ def __init__(self, http: HttpClient) -> None:
19
+ self._http = http
20
+
21
+ def get(self, owner: str, repo: str) -> Repository:
22
+ """Fetch a repository by owner and name.
23
+
24
+ Args:
25
+ owner: Repository owner username or organisation.
26
+ repo: Repository name.
27
+
28
+ Returns:
29
+ A :class:`~git_alternative.models.Repository` instance.
30
+
31
+ Raises:
32
+ ForgeNotFoundError: If the repository does not exist.
33
+ """
34
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}")
35
+ return Repository(**data)
36
+
37
+ def list(self, owner: str) -> list[Repository]:
38
+ """List all repositories for a user or organisation.
39
+
40
+ Args:
41
+ owner: Username or organisation name.
42
+
43
+ Returns:
44
+ List of :class:`~git_alternative.models.Repository` instances.
45
+ """
46
+ data = self._http.get(f"/api/v1/users/{owner}/repos")
47
+ return [Repository(**r) for r in data]
48
+
49
+ def create(
50
+ self,
51
+ name: str,
52
+ description: str | None = None,
53
+ is_private: bool = False,
54
+ default_branch: str = "main",
55
+ ) -> Repository:
56
+ """Create a new repository.
57
+
58
+ Args:
59
+ name: Repository name.
60
+ description: Optional description.
61
+ is_private: Whether the repository is private (default: False).
62
+ default_branch: Default branch name (default: ``main``).
63
+
64
+ Returns:
65
+ The newly created :class:`~git_alternative.models.Repository`.
66
+ """
67
+ payload: dict[str, Any] = {
68
+ "name": name,
69
+ "is_private": is_private,
70
+ "default_branch": default_branch,
71
+ }
72
+ if description is not None:
73
+ payload["description"] = description
74
+ data = self._http.post("/api/v1/repos", json=payload)
75
+ return Repository(**data)
76
+
77
+ def delete(self, owner: str, repo: str) -> None:
78
+ """Delete a repository.
79
+
80
+ Args:
81
+ owner: Repository owner.
82
+ repo: Repository name.
83
+
84
+ Raises:
85
+ ForgeNotFoundError: If the repository does not exist.
86
+ """
87
+ self._http.delete(f"/api/v1/repos/{owner}/{repo}")
88
+
89
+ def update(
90
+ self,
91
+ owner: str,
92
+ repo: str,
93
+ description: str | None = None,
94
+ is_private: bool | None = None,
95
+ default_branch: str | None = None,
96
+ ) -> Repository:
97
+ """Update repository metadata.
98
+
99
+ Args:
100
+ owner: Repository owner.
101
+ repo: Repository name.
102
+ description: New description (omit to leave unchanged).
103
+ is_private: New visibility (omit to leave unchanged).
104
+ default_branch: New default branch (omit to leave unchanged).
105
+
106
+ Returns:
107
+ The updated :class:`~git_alternative.models.Repository`.
108
+ """
109
+ payload: dict[str, Any] = {}
110
+ if description is not None:
111
+ payload["description"] = description
112
+ if is_private is not None:
113
+ payload["is_private"] = is_private
114
+ if default_branch is not None:
115
+ payload["default_branch"] = default_branch
116
+ data = self._http.patch(f"/api/v1/repos/{owner}/{repo}", json=payload)
117
+ return Repository(**data)
118
+
119
+ def fork(self, owner: str, repo: str) -> Repository:
120
+ """Fork a repository into the authenticated user's account.
121
+
122
+ Args:
123
+ owner: Source repository owner.
124
+ repo: Source repository name.
125
+
126
+ Returns:
127
+ The newly created fork as a :class:`~git_alternative.models.Repository`.
128
+ """
129
+ data = self._http.post(f"/api/v1/repos/{owner}/{repo}/forks")
130
+ return Repository(**data)
131
+
132
+ def list_branches(self, owner: str, repo: str) -> list[Branch]:
133
+ """List all branches in a repository.
134
+
135
+ Args:
136
+ owner: Repository owner.
137
+ repo: Repository name.
138
+
139
+ Returns:
140
+ List of :class:`~git_alternative.models.Branch` instances.
141
+ """
142
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/branches")
143
+ return [Branch(**b) for b in data]
144
+
145
+ def list_commits(
146
+ self,
147
+ owner: str,
148
+ repo: str,
149
+ branch: str | None = None,
150
+ page: int = 1,
151
+ per_page: int = 30,
152
+ ) -> PaginatedResponse[Commit]:
153
+ """List commits in a repository with pagination.
154
+
155
+ Args:
156
+ owner: Repository owner.
157
+ repo: Repository name.
158
+ branch: Branch name to list commits for (default: default branch).
159
+ page: Page number (1-based, default: 1).
160
+ per_page: Items per page (max: 100, default: 30).
161
+
162
+ Returns:
163
+ A :class:`~git_alternative.models.PaginatedResponse` of
164
+ :class:`~git_alternative.models.Commit` instances.
165
+ """
166
+ params: dict[str, Any] = {"page": page, "per_page": per_page}
167
+ if branch:
168
+ params["branch"] = branch
169
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/commits", params=params)
170
+ commits = [Commit(**c) for c in data.get("items", data if isinstance(data, list) else [])]
171
+ if isinstance(data, dict):
172
+ return PaginatedResponse[Commit](
173
+ items=commits,
174
+ page=data.get("page", page),
175
+ per_page=data.get("per_page", per_page),
176
+ has_next_page=data.get("has_next_page", False),
177
+ total_count=data.get("total_count"),
178
+ )
179
+ return PaginatedResponse[Commit](
180
+ items=commits,
181
+ page=page,
182
+ per_page=per_page,
183
+ has_next_page=False,
184
+ )
@@ -0,0 +1,64 @@
1
+ """SSH key resource manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from git_alternative.http import HttpSession
6
+ from git_alternative.utils import compute_key_fingerprint
7
+
8
+
9
+ class SshKeyResource:
10
+ """Manage SSH keys for Forge users.
11
+
12
+ Args:
13
+ session: An authenticated :class:`~git_alternative.http.HttpSession`.
14
+ """
15
+
16
+ def __init__(self, session: HttpSession) -> None:
17
+ self._session = session
18
+
19
+ def list(self, username: str) -> list[dict]:
20
+ """List SSH keys for a user.
21
+
22
+ Args:
23
+ username: The Forge username.
24
+
25
+ Returns:
26
+ A list of SSH key dicts with ``id``, ``title``, ``key``, and
27
+ ``fingerprint`` fields.
28
+ """
29
+ data = self._session.get(f"users/{username}/keys")
30
+ return data if isinstance(data, list) else data.get("items", [])
31
+
32
+ def add(self, username: str, key: str, title: str) -> dict:
33
+ """Add an SSH public key for a user.
34
+
35
+ The fingerprint is computed locally before submission so that the
36
+ server can enforce uniqueness on the canonical key material.
37
+
38
+ Args:
39
+ username: The Forge username.
40
+ key: The SSH public key string (``"<type> <base64> [comment]"``).
41
+ title: A human-readable label for the key.
42
+
43
+ Returns:
44
+ The created SSH key dict.
45
+
46
+ Raises:
47
+ ForgeValidationError: If the key string is malformed.
48
+ ForgeConflictError: If a key with the same fingerprint already exists.
49
+ """
50
+ fingerprint = compute_key_fingerprint(key)
51
+ data = self._session.post(
52
+ f"users/{username}/keys",
53
+ json={"key": key, "title": title, "fingerprint": fingerprint},
54
+ )
55
+ return data
56
+
57
+ def delete(self, username: str, key_id: str) -> None:
58
+ """Delete an SSH key.
59
+
60
+ Args:
61
+ username: The Forge username.
62
+ key_id: The key UUID.
63
+ """
64
+ self._session.delete(f"users/{username}/keys/{key_id}")