pygitcode 0.1.2a0__py3-none-any.whl → 0.1.4__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.
gitcode_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.2a0"
3
+ __version__ = "0.1.4"
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import AdapterActionResult
4
+ from .issues import IssueAdapter
5
+ from .pulls import PullRequestAdapter
6
+
7
+ __all__ = ["AdapterActionResult", "IssueAdapter", "PullRequestAdapter"]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class AdapterActionResult:
9
+ item: dict[str, Any] | None = None
10
+ items: list[dict[str, Any]] | None = None
11
+ message: str | None = None
12
+ warning: str | None = None
13
+ degraded: bool = False
14
+ approximated: bool = False
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ CAPABILITY_MESSAGES = {
6
+ "ISSUE_COMMENT_LAST_NOT_FOUND": "No editable issue comment from the current user was found.",
7
+ "ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE": (
8
+ "Unable to verify the current user's issue comments on GitCode; refusing to edit or delete comments safely."
9
+ ),
10
+ "ISSUE_CREATE_IF_NONE_REQUIRES_EDIT_LAST": "--create-if-none can only be used together with --edit-last.",
11
+ "ISSUE_DEVELOP_BASE": "--base and --name are not supported by 'gc issue develop'",
12
+ "ISSUE_DEVELOP_NAME": "--base and --name are not supported by 'gc issue develop'",
13
+ "ISSUE_LIST_APP": "GitCode issue API does not support --app filtering.",
14
+ "ISSUE_STATUS_GH_SEMANTICS": (
15
+ "GitCode-limited approximation of gh issue status: assigned and opened issues use user-level "
16
+ "GitCode results; mentioned issues use repository mention filtering."
17
+ ),
18
+ "PR_MERGE_AUTHOR_EMAIL": "GitCode merge API does not support --author-email.",
19
+ "PR_MERGE_AUTO": "GitCode merge API does not support --auto.",
20
+ "PR_EDIT_BASE": (
21
+ "GitCode's pull request update API does not support changing the base branch; "
22
+ "-B/--base cannot be used with 'gc pr edit'."
23
+ ),
24
+ "PR_REVIEW_REQUEST_CHANGES": (
25
+ "GitCode review API does not support request-changes reviews; the pull request comment was posted instead."
26
+ ),
27
+ "PR_STATUS_GH_SEMANTICS": ("GitCode API approximation -- user-specific filtering is not available"),
28
+ }
29
+
30
+
31
+ def capability_message(feature: str) -> str:
32
+ return CAPABILITY_MESSAGES[feature]
33
+
34
+
35
+ def unsupported(feature: str) -> click.ClickException:
36
+ return click.ClickException(capability_message(feature))
@@ -0,0 +1,373 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from ..errors import APIError
8
+ from ..services import IssueService, UserService
9
+ from .base import AdapterActionResult
10
+ from .capabilities import capability_message
11
+
12
+
13
+ def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
14
+ if not values:
15
+ return None
16
+ return ",".join(values)
17
+
18
+
19
+ def _normalize_labels(labels: tuple[str, ...] | None) -> tuple[str, ...] | None:
20
+ if not labels:
21
+ return None
22
+ normalized = tuple(label.strip() for label in labels if label.strip())
23
+ return normalized or None
24
+
25
+
26
+ def _extract_label_names(issue: dict[str, Any] | None) -> list[str]:
27
+ labels = issue.get("labels") if isinstance(issue, dict) else None
28
+ if not labels:
29
+ return []
30
+ if isinstance(labels, str):
31
+ return [part.strip() for part in labels.split(",") if part.strip()]
32
+
33
+ names: list[str] = []
34
+ if isinstance(labels, list):
35
+ for label in labels:
36
+ if isinstance(label, dict):
37
+ name = label.get("name") or label.get("title")
38
+ if name:
39
+ names.append(str(name))
40
+ elif label:
41
+ names.append(str(label))
42
+ return names
43
+
44
+
45
+ def _extract_assignee_names(issue: dict[str, Any] | None) -> list[str]:
46
+ assignees = issue.get("assignees") if isinstance(issue, dict) else None
47
+ if isinstance(assignees, str):
48
+ return [part.strip() for part in assignees.split(",") if part.strip()]
49
+ if isinstance(assignees, list):
50
+ names: list[str] = []
51
+ for assignee in assignees:
52
+ if isinstance(assignee, dict):
53
+ name = _extract_login(assignee)
54
+ if name:
55
+ names.append(name)
56
+ elif assignee:
57
+ names.append(str(assignee))
58
+ return names
59
+
60
+ assignee = issue.get("assignee") if isinstance(issue, dict) else None
61
+ if isinstance(assignee, str):
62
+ return [part.strip() for part in assignee.split(",") if part.strip()]
63
+ if isinstance(assignee, dict):
64
+ name = _extract_login(assignee)
65
+ return [name] if name else []
66
+ return []
67
+
68
+
69
+ def _matches_all_labels(issue: dict[str, Any], labels: tuple[str, ...] | None) -> bool:
70
+ if not labels or len(labels) < 2:
71
+ return True
72
+ actual = {label.lower() for label in _extract_label_names(issue)}
73
+ return all(label.lower() in actual for label in labels)
74
+
75
+
76
+ def _extract_login(data: dict[str, Any] | None) -> str | None:
77
+ if not isinstance(data, dict):
78
+ return None
79
+ for key in ("login", "username", "name"):
80
+ value = data.get(key)
81
+ if isinstance(value, str) and value.strip():
82
+ return value.strip()
83
+ user = data.get("user")
84
+ if isinstance(user, dict):
85
+ return _extract_login(user)
86
+ author = data.get("author")
87
+ if isinstance(author, dict):
88
+ return _extract_login(author)
89
+ return None
90
+
91
+
92
+ def _as_list(items: Any) -> list[Any]:
93
+ return items if isinstance(items, list) else []
94
+
95
+
96
+ def _extract_label_options(items: list[Any]) -> set[str]:
97
+ names: set[str] = set()
98
+ for item in items:
99
+ if not isinstance(item, dict):
100
+ continue
101
+ name = item.get("name")
102
+ if isinstance(name, str) and name.strip():
103
+ names.add(name.strip())
104
+ return names
105
+
106
+
107
+ def _resolve_milestone_number(items: list[Any], milestone: str | None) -> int | None:
108
+ if milestone is None:
109
+ return None
110
+ value = milestone.strip()
111
+ if not value:
112
+ return None
113
+ if value.isdigit():
114
+ return int(value)
115
+ matches: list[int] = []
116
+ for item in items:
117
+ if not isinstance(item, dict):
118
+ continue
119
+ title = item.get("title")
120
+ number = item.get("number")
121
+ if title != value:
122
+ continue
123
+ if isinstance(number, int):
124
+ matches.append(number)
125
+ elif isinstance(number, str) and number.isdigit():
126
+ matches.append(int(number))
127
+ if len(matches) == 1:
128
+ return matches[0]
129
+ if len(matches) > 1:
130
+ raise click.ClickException(f"could not resolve milestone: '{value}' matched multiple milestones")
131
+ raise click.ClickException(f"could not resolve milestone: '{value}' not found")
132
+
133
+
134
+ class IssueAdapter:
135
+ def __init__(self, service: IssueService, user_service: UserService | None = None):
136
+ self.service = service
137
+ self.user_service = user_service
138
+
139
+ def _list_all_labels(self, owner: str, repo: str) -> list[Any]:
140
+ items: list[Any] = []
141
+ page = 1
142
+ while True:
143
+ page_items = _as_list(self.service.list_labels(owner, repo, page=page, per_page=100))
144
+ items.extend(page_items)
145
+ if len(page_items) < 100:
146
+ return items
147
+ page += 1
148
+
149
+ def _list_all_milestones(self, owner: str, repo: str) -> list[Any]:
150
+ items: list[Any] = []
151
+ page = 1
152
+ while True:
153
+ page_items = _as_list(self.service.list_milestones(owner, repo, state="all", page=page, per_page=100))
154
+ items.extend(page_items)
155
+ if len(page_items) < 100:
156
+ return items
157
+ page += 1
158
+
159
+ def list_issues(
160
+ self,
161
+ owner: str,
162
+ repo: str,
163
+ *,
164
+ state: str | None,
165
+ labels: tuple[str, ...] | None,
166
+ author: str | None,
167
+ assignee: str | None,
168
+ milestone: str | None,
169
+ mention: str | None,
170
+ search: str | None,
171
+ limit: int | None,
172
+ ) -> Any:
173
+ if author == "@me":
174
+ if self.user_service is None:
175
+ raise click.ClickException("failed to resolve @me: current user lookup is unavailable")
176
+ current_login = _extract_login(self.user_service.current())
177
+ if not current_login:
178
+ raise click.ClickException("failed to resolve @me: current user has no login")
179
+ author = current_login
180
+ items = self.service.list(
181
+ owner,
182
+ repo,
183
+ state=state,
184
+ labels=_normalize_multi_values(labels),
185
+ creator=author,
186
+ assignee=assignee,
187
+ milestone=milestone,
188
+ mention=mention,
189
+ search=search,
190
+ )
191
+ if isinstance(items, list) and labels and len(labels) > 1:
192
+ items = [item for item in items if _matches_all_labels(item, labels)]
193
+ if limit is not None:
194
+ return items[:limit]
195
+ return items
196
+
197
+ def create_issue(
198
+ self,
199
+ owner: str,
200
+ repo: str,
201
+ *,
202
+ title: str,
203
+ body: str | None,
204
+ assignee: str | None,
205
+ labels: tuple[str, ...] | None,
206
+ milestone: str | None,
207
+ ) -> dict[str, Any] | None:
208
+ normalized_labels = _normalize_labels(labels)
209
+ if normalized_labels:
210
+ available_labels = _extract_label_options(self._list_all_labels(owner, repo))
211
+ for label in normalized_labels:
212
+ if label not in available_labels:
213
+ raise click.ClickException(f"could not add label: '{label}' not found")
214
+ milestone_number = None
215
+ if milestone is not None:
216
+ milestones = []
217
+ if not milestone.strip().isdigit():
218
+ milestones = self._list_all_milestones(owner, repo)
219
+ milestone_number = _resolve_milestone_number(milestones, milestone)
220
+ return self.service.create(
221
+ owner,
222
+ repo,
223
+ title=title,
224
+ body=body,
225
+ assignee=assignee,
226
+ labels=_normalize_multi_values(normalized_labels),
227
+ milestone=milestone_number,
228
+ )
229
+
230
+ def close_issue(
231
+ self,
232
+ owner: str,
233
+ repo: str,
234
+ number: str,
235
+ *,
236
+ comment: str | None,
237
+ reason: str | None,
238
+ ) -> AdapterActionResult:
239
+ current = self.service.get(owner, repo, number)
240
+ if current and current.get("state") == "closed":
241
+ if comment:
242
+ self.service.comment(owner, repo, number, comment)
243
+ return AdapterActionResult(item=current, message="already_closed_commented")
244
+ return AdapterActionResult(item=current, message="already_closed")
245
+ if comment:
246
+ self.service.comment(owner, repo, number, comment)
247
+ payload: dict[str, Any] = {"state": "close"}
248
+ if reason:
249
+ payload["state_reason"] = reason
250
+ item = self.service.update(owner, repo, number, **payload)
251
+ return AdapterActionResult(item=item, message="closed")
252
+
253
+ def comment_issue(self, owner: str, repo: str, number: str, *, body: str) -> dict[str, Any] | None:
254
+ return self.service.comment(owner, repo, number, body)
255
+
256
+ def manage_comment_history(
257
+ self,
258
+ owner: str,
259
+ repo: str,
260
+ number: str,
261
+ *,
262
+ body: str | None,
263
+ delete_last: bool,
264
+ create_if_none: bool,
265
+ ) -> AdapterActionResult:
266
+ comment = self._find_last_owned_comment(owner, repo, number)
267
+ if delete_last:
268
+ if comment is None:
269
+ raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
270
+ self.service.delete_comment(owner, repo, comment["id"])
271
+ return AdapterActionResult(item=comment, message="deleted")
272
+ if comment is None:
273
+ if create_if_none:
274
+ item = self.service.comment(owner, repo, number, body or "")
275
+ return AdapterActionResult(item=item, message="created")
276
+ raise click.ClickException(capability_message("ISSUE_COMMENT_LAST_NOT_FOUND"))
277
+ item = self.service.update_comment(owner, repo, comment["id"], body or "")
278
+ return AdapterActionResult(item=item, message="edited")
279
+
280
+ def _find_last_owned_comment(self, owner: str, repo: str, number: str) -> dict[str, Any] | None:
281
+ if self.user_service is None:
282
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
283
+ try:
284
+ current_user = self.user_service.current()
285
+ except APIError as exc:
286
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE")) from exc
287
+ current_login = _extract_login(current_user)
288
+ if not current_login:
289
+ raise click.ClickException(capability_message("ISSUE_COMMENT_OWNERSHIP_UNVERIFIABLE"))
290
+ comments = self.service.list_comments(owner, repo, number) or []
291
+ for comment in reversed(comments):
292
+ if not isinstance(comment, dict):
293
+ continue
294
+ if _extract_login(comment) == current_login:
295
+ return comment
296
+ return None
297
+
298
+ def reopen_issue(self, owner: str, repo: str, number: str, *, comment: str | None = None) -> AdapterActionResult:
299
+ current = self.service.get(owner, repo, number)
300
+ if current and current.get("state") == "open":
301
+ return AdapterActionResult(item=current, message="already_open")
302
+ item = self.service.update(owner, repo, number, state="reopen")
303
+ if comment:
304
+ self.service.comment(owner, repo, number, comment)
305
+ return AdapterActionResult(item=item, message="reopened")
306
+
307
+ def delete_issue(self, owner: str, repo: str, number: str) -> AdapterActionResult:
308
+ item = self.service.delete(owner, repo, number)
309
+ return AdapterActionResult(item=item, message="deleted")
310
+
311
+ def edit_issue(
312
+ self,
313
+ owner: str,
314
+ repo: str,
315
+ number: str,
316
+ *,
317
+ title: str | None,
318
+ body: str | None,
319
+ add_assignee: str | None,
320
+ add_labels: tuple[str, ...] | None,
321
+ remove_assignee: str | None,
322
+ remove_labels: tuple[str, ...] | None,
323
+ milestone: int | None,
324
+ remove_milestone: bool,
325
+ ) -> dict[str, Any] | None:
326
+ data: dict[str, Any] = {k: v for k, v in {"title": title, "body": body}.items() if v is not None}
327
+
328
+ if add_assignee is not None:
329
+ data["assignee"] = add_assignee
330
+ if remove_assignee is not None:
331
+ current = self.service.get(owner, repo, number)
332
+ remaining = [name for name in _extract_assignee_names(current) if name != remove_assignee]
333
+ data["assignee"] = ",".join(remaining)
334
+
335
+ if add_labels or remove_labels:
336
+ current = self.service.get(owner, repo, number)
337
+ existing = _extract_label_names(current)
338
+ removed = {label.strip() for label in remove_labels or () if label.strip()}
339
+ merged: list[str] = []
340
+ seen: set[str] = set()
341
+ for label in [*existing, *list(add_labels or ())]:
342
+ normalized = label.strip()
343
+ if normalized and normalized not in removed and normalized not in seen:
344
+ seen.add(normalized)
345
+ merged.append(normalized)
346
+ data["labels"] = ",".join(merged)
347
+
348
+ if milestone is not None:
349
+ data["milestone"] = milestone
350
+ if remove_milestone:
351
+ data["milestone"] = 0
352
+ return self.service.update(owner, repo, number, **data)
353
+
354
+ def status(self, owner: str, repo: str) -> AdapterActionResult:
355
+ if self.user_service is None:
356
+ raise click.ClickException("failed to resolve current user: current user lookup is unavailable")
357
+ current_login = _extract_login(self.user_service.current())
358
+ if not current_login:
359
+ raise click.ClickException("failed to resolve current user: current user has no login")
360
+ assigned = self.service.list_user_issues(filter="assigned", state="open") or []
361
+ created = self.service.list_user_issues(filter="created", state="open") or []
362
+ mentioned = self.service.list(owner, repo, state="open", mention=current_login) or []
363
+ return AdapterActionResult(
364
+ item={"assigned": assigned, "mentioned": mentioned, "createdBy": created},
365
+ message=capability_message("ISSUE_STATUS_GH_SEMANTICS"),
366
+ approximated=True,
367
+ )
368
+
369
+ def develop(self, owner: str, repo: str, number: str, *, base: str | None, name: str | None) -> AdapterActionResult: # noqa: ARG002
370
+ return AdapterActionResult(
371
+ message=f"Opening issue #{number} in the browser instead.",
372
+ warning="Note: 'issue develop' does not create a local branch on GitCode.",
373
+ )
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..services import PullRequestService
6
+ from .base import AdapterActionResult
7
+ from .capabilities import capability_message
8
+
9
+
10
+ def _normalize_multi_values(values: tuple[str, ...] | None) -> str | None:
11
+ if not values:
12
+ return None
13
+ return ",".join(values)
14
+
15
+
16
+ def _normalize_limit(limit: int | None) -> tuple[int, int | None]:
17
+ if limit is None:
18
+ return 30, 30
19
+ if limit < 0:
20
+ return 100, None
21
+ if limit == 0:
22
+ return 0, 0
23
+ return min(limit, 100), limit
24
+
25
+
26
+ class PullRequestAdapter:
27
+ def __init__(self, service: PullRequestService):
28
+ self.service = service
29
+
30
+ def list_prs(
31
+ self,
32
+ owner: str,
33
+ repo: str,
34
+ *,
35
+ state: str | None,
36
+ author: str | None,
37
+ base: str | None,
38
+ assignee: str | None,
39
+ draft: bool | None,
40
+ head: str | None,
41
+ labels: tuple[str, ...] | None,
42
+ search: str | None,
43
+ limit: int | None,
44
+ merged_after: str | None = None,
45
+ merged_before: str | None = None,
46
+ ) -> Any:
47
+ params = {
48
+ "state": state,
49
+ "author": author,
50
+ "base": base,
51
+ "assignee": assignee,
52
+ "draft": draft,
53
+ "head": head,
54
+ "labels": _normalize_multi_values(labels),
55
+ }
56
+ if search is not None:
57
+ params["search"] = search
58
+ if merged_after is not None:
59
+ params["merged_after"] = merged_after
60
+ if merged_before is not None:
61
+ params["merged_before"] = merged_before
62
+
63
+ per_page, remaining = _normalize_limit(limit)
64
+ if remaining == 0:
65
+ return []
66
+
67
+ items = []
68
+ page = 1
69
+ while True:
70
+ page_items = self.service.list(owner, repo, **params, page=page, per_page=per_page)
71
+ if not page_items:
72
+ break
73
+ items.extend(page_items)
74
+ if remaining is not None and len(items) >= remaining:
75
+ return items[:remaining]
76
+ if len(page_items) < per_page:
77
+ break
78
+ page += 1
79
+ return items
80
+
81
+ def create_pr(
82
+ self,
83
+ owner: str,
84
+ repo: str,
85
+ *,
86
+ title: str,
87
+ body: str | None,
88
+ base: str,
89
+ head: str,
90
+ draft: bool,
91
+ milestone: str | None,
92
+ labels: tuple[str, ...] | None,
93
+ reviewers: tuple[str, ...] | None,
94
+ assignees: tuple[str, ...] | None,
95
+ dry_run: bool,
96
+ ) -> AdapterActionResult:
97
+ payload = {
98
+ "title": title,
99
+ "body": body,
100
+ "base": base,
101
+ "head": head,
102
+ "draft": draft,
103
+ "labels": _normalize_multi_values(labels),
104
+ "assignees": _normalize_multi_values(assignees),
105
+ "reviewers": _normalize_multi_values(reviewers),
106
+ "milestone": milestone,
107
+ }
108
+ if dry_run:
109
+ return AdapterActionResult(item={k: v for k, v in payload.items() if v is not None})
110
+ return AdapterActionResult(item=self.service.create(owner, repo, **payload))
111
+
112
+ def merge_pr(
113
+ self,
114
+ owner: str,
115
+ repo: str,
116
+ number: int,
117
+ *,
118
+ merge_method: str,
119
+ body: str | None,
120
+ subject: str | None,
121
+ admin: bool,
122
+ ) -> dict[str, Any] | None:
123
+ payload = {
124
+ "merge_method": merge_method,
125
+ "description": body,
126
+ "title": subject,
127
+ "force_merge": True if admin else None,
128
+ }
129
+ return self.service.merge(owner, repo, number, **payload)
130
+
131
+ def review_pr(
132
+ self,
133
+ owner: str,
134
+ repo: str,
135
+ number: int,
136
+ *,
137
+ approve: bool, # noqa: ARG002
138
+ body: str | None,
139
+ comment: bool,
140
+ request_changes: bool,
141
+ force: bool,
142
+ ) -> AdapterActionResult:
143
+ if comment:
144
+ item = self.service.comment(owner, repo, number, body=body or "")
145
+ return AdapterActionResult(item=item)
146
+ if request_changes:
147
+ item = self.service.comment(owner, repo, number, body=body or "")
148
+ return AdapterActionResult(
149
+ item=item,
150
+ message=capability_message("PR_REVIEW_REQUEST_CHANGES"),
151
+ degraded=True,
152
+ )
153
+ item = self.service.review(owner, repo, number, force=force)
154
+ return AdapterActionResult(item=item)
155
+
156
+ def edit_pr(
157
+ self,
158
+ owner: str,
159
+ repo: str,
160
+ number: int,
161
+ *,
162
+ title: str | None,
163
+ body: str | None,
164
+ add_assignee: str | None,
165
+ add_label: str | None,
166
+ add_reviewer: str | None,
167
+ remove_assignee: str | None,
168
+ remove_label: str | None,
169
+ remove_reviewer: str | None,
170
+ milestone: str | None,
171
+ remove_milestone: bool,
172
+ ) -> dict[str, Any] | None:
173
+ data = {
174
+ k: v
175
+ for k, v in {
176
+ "title": title,
177
+ "body": body,
178
+ "assignee": add_assignee,
179
+ "labels": add_label,
180
+ "reviewer": add_reviewer,
181
+ "unassignee": remove_assignee,
182
+ "unset_labels": remove_label,
183
+ "unset_reviewer": remove_reviewer,
184
+ "milestone": milestone,
185
+ }.items()
186
+ if v is not None
187
+ }
188
+ if remove_milestone:
189
+ data["milestone"] = ""
190
+ return self.service.update(owner, repo, number, **data)
191
+
192
+ def status(self, owner: str, repo: str) -> AdapterActionResult:
193
+ items = self.service.list(owner, repo, state="open")
194
+ return AdapterActionResult(
195
+ items=items,
196
+ message=capability_message("PR_STATUS_GH_SEMANTICS"),
197
+ approximated=True,
198
+ )