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,291 @@
1
+ """Data models for the Forge Git collaboration platform SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class IssueState(str, Enum):
14
+ """Lifecycle state of an issue."""
15
+
16
+ OPEN = "open"
17
+ CLOSED = "closed"
18
+
19
+
20
+ class PrState(str, Enum):
21
+ """Lifecycle state of a pull request."""
22
+
23
+ OPEN = "open"
24
+ MERGED = "merged"
25
+ CLOSED = "closed"
26
+ DRAFT = "draft"
27
+
28
+
29
+ class MergeStrategy(str, Enum):
30
+ """Strategy used to merge a pull request."""
31
+
32
+ MERGE = "merge"
33
+ SQUASH = "squash"
34
+ REBASE = "rebase"
35
+
36
+
37
+ class PipelineState(str, Enum):
38
+ """Execution state of a CI pipeline or job."""
39
+
40
+ PENDING = "pending"
41
+ RUNNING = "running"
42
+ SUCCESS = "success"
43
+ FAILED = "failed"
44
+ CANCELLED = "cancelled"
45
+
46
+
47
+ class PipelineTrigger(str, Enum):
48
+ """Event that triggered a CI pipeline."""
49
+
50
+ PUSH = "push"
51
+ PULL_REQUEST = "pull_request"
52
+ MANUAL = "manual"
53
+
54
+
55
+ @dataclass
56
+ class User:
57
+ """Represents a Forge user account."""
58
+
59
+ id: str
60
+ username: str
61
+ email: str
62
+ display_name: str
63
+ created_at: datetime
64
+ updated_at: datetime
65
+ bio: str | None = None
66
+ avatar_url: str | None = None
67
+
68
+
69
+ @dataclass
70
+ class Repository:
71
+ """Represents a Forge repository."""
72
+
73
+ id: str
74
+ owner_id: str
75
+ name: str
76
+ description: str | None
77
+ is_private: bool
78
+ is_fork: bool
79
+ default_branch: str
80
+ created_at: datetime
81
+ updated_at: datetime
82
+ star_count: int = 0
83
+ fork_count: int = 0
84
+ fork_of: str | None = None
85
+
86
+
87
+ @dataclass
88
+ class CreateRepoRequest:
89
+ """Request payload for creating a new repository."""
90
+
91
+ name: str
92
+ description: str | None = None
93
+ is_private: bool = False
94
+ default_branch: str = "main"
95
+ auto_init: bool = False
96
+
97
+
98
+ @dataclass
99
+ class Branch:
100
+ """Represents a git branch."""
101
+
102
+ name: str
103
+ sha: str
104
+ is_default: bool = False
105
+ is_protected: bool = False
106
+
107
+
108
+ @dataclass
109
+ class CommitSummary:
110
+ """Summary of a git commit."""
111
+
112
+ sha: str
113
+ message: str
114
+ author_name: str
115
+ author_email: str
116
+ authored_at: datetime
117
+ committer_name: str
118
+ committer_email: str
119
+ committed_at: datetime
120
+ parents: list[str] = field(default_factory=list)
121
+
122
+
123
+ @dataclass
124
+ class Label:
125
+ """Represents an issue/PR label."""
126
+
127
+ id: str
128
+ name: str
129
+ color: str
130
+ description: str | None = None
131
+
132
+
133
+ @dataclass
134
+ class Issue:
135
+ """Represents a Forge issue."""
136
+
137
+ id: str
138
+ repo_id: str
139
+ number: int
140
+ title: str
141
+ author_id: str
142
+ state: IssueState
143
+ created_at: datetime
144
+ updated_at: datetime
145
+ body: str | None = None
146
+ labels: list[Label] = field(default_factory=list)
147
+ assignees: list[str] = field(default_factory=list)
148
+ milestone_id: str | None = None
149
+ closed_at: datetime | None = None
150
+ comment_count: int = 0
151
+
152
+
153
+ @dataclass
154
+ class Comment:
155
+ """Represents a comment on an issue or pull request."""
156
+
157
+ id: str
158
+ author_id: str
159
+ body: str
160
+ created_at: datetime
161
+ updated_at: datetime
162
+
163
+
164
+ @dataclass
165
+ class Reviewer:
166
+ """Represents a PR reviewer and their approval status."""
167
+
168
+ user_id: str
169
+ approved: bool = False
170
+ reviewed_at: datetime | None = None
171
+
172
+
173
+ @dataclass
174
+ class PullRequest:
175
+ """Represents a Forge pull request."""
176
+
177
+ id: str
178
+ repo_id: str
179
+ number: int
180
+ title: str
181
+ author_id: str
182
+ state: PrState
183
+ head_branch: str
184
+ head_sha: str
185
+ base_branch: str
186
+ base_sha: str
187
+ created_at: datetime
188
+ updated_at: datetime
189
+ body: str | None = None
190
+ merge_sha: str | None = None
191
+ merge_strategy: MergeStrategy | None = None
192
+ reviewers: list[Reviewer] = field(default_factory=list)
193
+ merged_at: datetime | None = None
194
+ has_conflicts: bool = False
195
+
196
+
197
+ @dataclass
198
+ class PipelineJob:
199
+ """Represents a single job within a CI pipeline."""
200
+
201
+ id: str
202
+ name: str
203
+ state: PipelineState
204
+ started_at: datetime | None = None
205
+ finished_at: datetime | None = None
206
+ steps: list[dict[str, Any]] = field(default_factory=list)
207
+
208
+
209
+ @dataclass
210
+ class Pipeline:
211
+ """Represents a CI pipeline run."""
212
+
213
+ id: str
214
+ repo_id: str
215
+ commit_sha: str
216
+ branch: str
217
+ trigger: PipelineTrigger
218
+ state: PipelineState
219
+ created_at: datetime
220
+ jobs: list[PipelineJob] = field(default_factory=list)
221
+ started_at: datetime | None = None
222
+ finished_at: datetime | None = None
223
+
224
+
225
+ @dataclass
226
+ class CommitStatus:
227
+ """Aggregated CI status for a commit."""
228
+
229
+ sha: str
230
+ state: PipelineState
231
+ pipeline_count: int = 0
232
+
233
+
234
+ @dataclass
235
+ class SshKey:
236
+ """Represents an SSH public key attached to a user account."""
237
+
238
+ id: str
239
+ user_id: str
240
+ title: str
241
+ key: str
242
+ fingerprint: str
243
+ created_at: datetime
244
+
245
+
246
+ @dataclass
247
+ class PaginatedResponse(Generic[T]):
248
+ """A paginated list of items from the API."""
249
+
250
+ items: list[T]
251
+ page: int
252
+ per_page: int
253
+ has_next_page: bool
254
+ total_count: int | None = None
255
+
256
+
257
+ @dataclass
258
+ class CIJobStep:
259
+ """A single step within a CI job definition."""
260
+
261
+ name: str
262
+ run: str | None = None
263
+ uses: str | None = None
264
+ with_params: dict[str, Any] = field(default_factory=dict)
265
+
266
+
267
+ @dataclass
268
+ class CIJobDefinition:
269
+ """Definition of a CI job from the pipeline config file."""
270
+
271
+ name: str
272
+ runs_on: str
273
+ steps: list[CIJobStep] = field(default_factory=list)
274
+ needs: list[str] = field(default_factory=list)
275
+
276
+
277
+ @dataclass
278
+ class CITrigger:
279
+ """Trigger conditions for a CI pipeline."""
280
+
281
+ push_branches: list[str] = field(default_factory=list)
282
+ pr_branches: list[str] = field(default_factory=list)
283
+
284
+
285
+ @dataclass
286
+ class CIConfig:
287
+ """Parsed representation of a .forge/ci.yml configuration file."""
288
+
289
+ name: str
290
+ trigger: CITrigger
291
+ jobs: dict[str, CIJobDefinition] = field(default_factory=dict)
@@ -0,0 +1,45 @@
1
+ """Pagination utilities for the Forge API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from git_alternative.models import PaginatedResponse, T
6
+
7
+
8
+ def paginate_commits(
9
+ raw_commits: list[T],
10
+ page: int,
11
+ per_page: int,
12
+ ) -> PaginatedResponse[T]:
13
+ """Apply server-side pagination logic to a list of commits.
14
+
15
+ Fetches ``per_page + 1`` items and uses the extra item as a sentinel to
16
+ determine whether a next page exists, without requiring an expensive
17
+ ``COUNT(*)`` query.
18
+
19
+ Args:
20
+ raw_commits: List of commit objects fetched with ``limit = per_page + 1``.
21
+ page: The current page number (1-based).
22
+ per_page: Maximum number of items per page.
23
+
24
+ Returns:
25
+ A :class:`~git_alternative.models.PaginatedResponse` with the correct
26
+ items slice and ``has_next_page`` flag.
27
+
28
+ Example:
29
+ >>> items = list(range(11)) # fetched with limit=11
30
+ >>> result = paginate_commits(items, page=1, per_page=10)
31
+ >>> result.has_next_page
32
+ True
33
+ >>> len(result.items)
34
+ 10
35
+ """
36
+ per_page = min(per_page, 100)
37
+ has_next = len(raw_commits) > per_page
38
+ items = raw_commits[:per_page] if has_next else raw_commits
39
+
40
+ return PaginatedResponse(
41
+ items=items,
42
+ page=page,
43
+ per_page=per_page,
44
+ has_next_page=has_next,
45
+ )
@@ -0,0 +1 @@
1
+ """Resource sub-clients for the Forge API."""
@@ -0,0 +1,291 @@
1
+ """Issues 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 Comment, Issue, IssueState, Label, PaginatedResponse
9
+
10
+
11
+ class IssuesResource:
12
+ """Client for issue-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 list(
22
+ self,
23
+ owner: str,
24
+ repo: str,
25
+ state: str = "open",
26
+ label: str | None = None,
27
+ assignee: str | None = None,
28
+ page: int = 1,
29
+ per_page: int = 30,
30
+ ) -> PaginatedResponse[Issue]:
31
+ """List issues in a repository.
32
+
33
+ Args:
34
+ owner: Repository owner.
35
+ repo: Repository name.
36
+ state: Filter by state — ``"open"``, ``"closed"``, or ``"all"``
37
+ (default: ``"open"``).
38
+ label: Filter by label name.
39
+ assignee: Filter by assignee username.
40
+ page: Page number (1-based, default: 1).
41
+ per_page: Items per page (max: 100, default: 30).
42
+
43
+ Returns:
44
+ A :class:`~git_alternative.models.PaginatedResponse` of
45
+ :class:`~git_alternative.models.Issue` instances.
46
+ """
47
+ params: dict[str, Any] = {"state": state, "page": page, "per_page": per_page}
48
+ if label:
49
+ params["label"] = label
50
+ if assignee:
51
+ params["assignee"] = assignee
52
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/issues", params=params)
53
+ items = data.get("items", data if isinstance(data, list) else [])
54
+ issues = [Issue(**i) for i in items]
55
+ if isinstance(data, dict):
56
+ return PaginatedResponse[Issue](
57
+ items=issues,
58
+ page=data.get("page", page),
59
+ per_page=data.get("per_page", per_page),
60
+ has_next_page=data.get("has_next_page", False),
61
+ total_count=data.get("total_count"),
62
+ )
63
+ return PaginatedResponse[Issue](
64
+ items=issues,
65
+ page=page,
66
+ per_page=per_page,
67
+ has_next_page=False,
68
+ )
69
+
70
+ def get(self, owner: str, repo: str, number: int) -> Issue:
71
+ """Fetch a single issue by number.
72
+
73
+ Args:
74
+ owner: Repository owner.
75
+ repo: Repository name.
76
+ number: Issue number.
77
+
78
+ Returns:
79
+ An :class:`~git_alternative.models.Issue` instance.
80
+
81
+ Raises:
82
+ ForgeNotFoundError: If the issue does not exist.
83
+ """
84
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/issues/{number}")
85
+ return Issue(**data)
86
+
87
+ def create(
88
+ self,
89
+ owner: str,
90
+ repo: str,
91
+ title: str,
92
+ body: str | None = None,
93
+ labels: list[str] | None = None,
94
+ assignees: list[str] | None = None,
95
+ milestone_id: str | None = None,
96
+ ) -> Issue:
97
+ """Create a new issue.
98
+
99
+ Args:
100
+ owner: Repository owner.
101
+ repo: Repository name.
102
+ title: Issue title.
103
+ body: Optional Markdown body.
104
+ labels: Optional list of label names to attach.
105
+ assignees: Optional list of usernames to assign.
106
+ milestone_id: Optional milestone UUID string.
107
+
108
+ Returns:
109
+ The newly created :class:`~git_alternative.models.Issue`.
110
+ """
111
+ payload: dict[str, Any] = {"title": title}
112
+ if body is not None:
113
+ payload["body"] = body
114
+ if labels:
115
+ payload["labels"] = labels
116
+ if assignees:
117
+ payload["assignees"] = assignees
118
+ if milestone_id:
119
+ payload["milestone_id"] = milestone_id
120
+ data = self._http.post(f"/api/v1/repos/{owner}/{repo}/issues", json=payload)
121
+ return Issue(**data)
122
+
123
+ def update(
124
+ self,
125
+ owner: str,
126
+ repo: str,
127
+ number: int,
128
+ title: str | None = None,
129
+ body: str | None = None,
130
+ state: IssueState | None = None,
131
+ ) -> Issue:
132
+ """Update an existing issue.
133
+
134
+ Args:
135
+ owner: Repository owner.
136
+ repo: Repository name.
137
+ number: Issue number.
138
+ title: New title (omit to leave unchanged).
139
+ body: New body (omit to leave unchanged).
140
+ state: New state (omit to leave unchanged).
141
+
142
+ Returns:
143
+ The updated :class:`~git_alternative.models.Issue`.
144
+ """
145
+ payload: dict[str, Any] = {}
146
+ if title is not None:
147
+ payload["title"] = title
148
+ if body is not None:
149
+ payload["body"] = body
150
+ if state is not None:
151
+ payload["state"] = state.value
152
+ data = self._http.patch(f"/api/v1/repos/{owner}/{repo}/issues/{number}", json=payload)
153
+ return Issue(**data)
154
+
155
+ def close(self, owner: str, repo: str, number: int) -> Issue:
156
+ """Close an issue.
157
+
158
+ Args:
159
+ owner: Repository owner.
160
+ repo: Repository name.
161
+ number: Issue number.
162
+
163
+ Returns:
164
+ The updated :class:`~git_alternative.models.Issue`.
165
+ """
166
+ return self.update(owner, repo, number, state=IssueState.CLOSED)
167
+
168
+ def reopen(self, owner: str, repo: str, number: int) -> Issue:
169
+ """Reopen a closed issue.
170
+
171
+ Args:
172
+ owner: Repository owner.
173
+ repo: Repository name.
174
+ number: Issue number.
175
+
176
+ Returns:
177
+ The updated :class:`~git_alternative.models.Issue`.
178
+ """
179
+ return self.update(owner, repo, number, state=IssueState.OPEN)
180
+
181
+ def delete(self, owner: str, repo: str, number: int) -> None:
182
+ """Delete an issue.
183
+
184
+ Args:
185
+ owner: Repository owner.
186
+ repo: Repository name.
187
+ number: Issue number.
188
+ """
189
+ self._http.delete(f"/api/v1/repos/{owner}/{repo}/issues/{number}")
190
+
191
+ def list_comments(self, owner: str, repo: str, number: int) -> list[Comment]:
192
+ """List comments on an issue.
193
+
194
+ Args:
195
+ owner: Repository owner.
196
+ repo: Repository name.
197
+ number: Issue number.
198
+
199
+ Returns:
200
+ List of :class:`~git_alternative.models.Comment` instances.
201
+ """
202
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/issues/{number}/comments")
203
+ return [Comment(**c) for c in data]
204
+
205
+ def add_comment(self, owner: str, repo: str, issue_number: int, body: str) -> Comment:
206
+ """Add a comment to an issue.
207
+
208
+ Args:
209
+ owner: Repository owner.
210
+ repo: Repository name.
211
+ issue_number: Issue number.
212
+ body: Comment body (Markdown supported).
213
+
214
+ Returns:
215
+ The newly created :class:`~git_alternative.models.Comment`.
216
+ """
217
+ data = self._http.post(
218
+ f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
219
+ json={"body": body},
220
+ )
221
+ return Comment(**data)
222
+
223
+ def update_comment(
224
+ self, owner: str, repo: str, issue_number: int, comment_id: str, body: str
225
+ ) -> Comment:
226
+ """Update an existing comment.
227
+
228
+ Args:
229
+ owner: Repository owner.
230
+ repo: Repository name.
231
+ issue_number: Issue number.
232
+ comment_id: Comment UUID string.
233
+ body: New comment body.
234
+
235
+ Returns:
236
+ The updated :class:`~git_alternative.models.Comment`.
237
+ """
238
+ data = self._http.patch(
239
+ f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments/{comment_id}",
240
+ json={"body": body},
241
+ )
242
+ return Comment(**data)
243
+
244
+ def delete_comment(
245
+ self, owner: str, repo: str, issue_number: int, comment_id: str
246
+ ) -> None:
247
+ """Delete a comment from an issue.
248
+
249
+ Args:
250
+ owner: Repository owner.
251
+ repo: Repository name.
252
+ issue_number: Issue number.
253
+ comment_id: Comment UUID string.
254
+ """
255
+ self._http.delete(
256
+ f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments/{comment_id}"
257
+ )
258
+
259
+ def list_labels(self, owner: str, repo: str) -> list[Label]:
260
+ """List all labels in a repository.
261
+
262
+ Args:
263
+ owner: Repository owner.
264
+ repo: Repository name.
265
+
266
+ Returns:
267
+ List of :class:`~git_alternative.models.Label` instances.
268
+ """
269
+ data = self._http.get(f"/api/v1/repos/{owner}/{repo}/labels")
270
+ return [Label(**lb) for lb in data]
271
+
272
+ def create_label(
273
+ self, owner: str, repo: str, name: str, color: str, description: str | None = None
274
+ ) -> Label:
275
+ """Create a new label in a repository.
276
+
277
+ Args:
278
+ owner: Repository owner.
279
+ repo: Repository name.
280
+ name: Label name.
281
+ color: Hex color string (e.g., ``"#ff0000"``).
282
+ description: Optional label description.
283
+
284
+ Returns:
285
+ The newly created :class:`~git_alternative.models.Label`.
286
+ """
287
+ payload: dict[str, Any] = {"name": name, "color": color}
288
+ if description is not None:
289
+ payload["description"] = description
290
+ data = self._http.post(f"/api/v1/repos/{owner}/{repo}/labels", json=payload)
291
+ return Label(**data)