pygitcode 0.1.2a0__tar.gz → 0.1.4__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.
Files changed (46) hide show
  1. pygitcode-0.1.4/PKG-INFO +110 -0
  2. pygitcode-0.1.4/README.md +70 -0
  3. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/pyproject.toml +5 -4
  4. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/__init__.py +1 -1
  5. pygitcode-0.1.4/src/gitcode_cli/adapters/__init__.py +7 -0
  6. pygitcode-0.1.4/src/gitcode_cli/adapters/base.py +14 -0
  7. pygitcode-0.1.4/src/gitcode_cli/adapters/capabilities.py +36 -0
  8. pygitcode-0.1.4/src/gitcode_cli/adapters/issues.py +373 -0
  9. pygitcode-0.1.4/src/gitcode_cli/adapters/pulls.py +198 -0
  10. pygitcode-0.1.4/src/gitcode_cli/cli.py +113 -0
  11. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/cli_compat.py +2 -0
  12. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/client.py +23 -9
  13. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/commands/auth.py +20 -6
  14. pygitcode-0.1.4/src/gitcode_cli/commands/compat.py +89 -0
  15. pygitcode-0.1.4/src/gitcode_cli/commands/issue.py +778 -0
  16. pygitcode-0.1.4/src/gitcode_cli/commands/pr.py +999 -0
  17. pygitcode-0.1.4/src/gitcode_cli/compat/__init__.py +10 -0
  18. pygitcode-0.1.4/src/gitcode_cli/compat/_types.py +48 -0
  19. pygitcode-0.1.4/src/gitcode_cli/compat/entries.py +459 -0
  20. pygitcode-0.1.4/src/gitcode_cli/compat/registry.py +146 -0
  21. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/errors.py +4 -0
  22. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/formatters.py +62 -40
  23. pygitcode-0.1.4/src/gitcode_cli/helptext.py +220 -0
  24. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/__init__.py +2 -1
  25. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/issues.py +17 -2
  26. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/pulls.py +20 -1
  27. pygitcode-0.1.4/src/gitcode_cli/services/users.py +13 -0
  28. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/utils.py +20 -4
  29. pygitcode-0.1.4/src/pygitcode.egg-info/PKG-INFO +110 -0
  30. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/SOURCES.txt +12 -0
  31. pygitcode-0.1.2a0/PKG-INFO +0 -283
  32. pygitcode-0.1.2a0/README.md +0 -243
  33. pygitcode-0.1.2a0/src/gitcode_cli/cli.py +0 -66
  34. pygitcode-0.1.2a0/src/gitcode_cli/commands/issue.py +0 -365
  35. pygitcode-0.1.2a0/src/gitcode_cli/commands/pr.py +0 -570
  36. pygitcode-0.1.2a0/src/pygitcode.egg-info/PKG-INFO +0 -283
  37. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/LICENSE +0 -0
  38. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/setup.cfg +0 -0
  39. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/commands/__init__.py +0 -0
  40. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/config.py +0 -0
  41. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/context.py +0 -0
  42. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/repo.py +0 -0
  43. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/dependency_links.txt +0 -0
  44. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/entry_points.txt +0 -0
  45. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/requires.txt +0 -0
  46. {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/top_level.txt +0 -0
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygitcode
3
+ Version: 0.1.4
4
+ Summary: A CLI tool for GitCode (api.gitcode.com), modeled after GitHub CLI.
5
+ Author-email: codeasier <825770651@qq.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/codeasier/gitcode-cli
8
+ Project-URL: Documentation, https://github.com/codeasier/gitcode-cli#readme
9
+ Project-URL: Repository, https://github.com/codeasier/gitcode-cli.git
10
+ Project-URL: Issues, https://github.com/codeasier/gitcode-cli/issues
11
+ Keywords: gitcode,cli,git,api,vcs,developer-tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Classifier: Topic :: Utilities
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: click>=8.0
29
+ Requires-Dist: httpx>=0.24
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
33
+ Requires-Dist: pytest-mock>=3.14; extra == "dev"
34
+ Requires-Dist: respx>=0.21; extra == "dev"
35
+ Requires-Dist: ruff>=0.6; extra == "dev"
36
+ Requires-Dist: black>=24.0; extra == "dev"
37
+ Requires-Dist: basedpyright>=1.19; extra == "dev"
38
+ Requires-Dist: pre-commit>=3.0; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # pygitcode
42
+
43
+ > A CLI tool for [GitCode](https://gitcode.com/) (`api.gitcode.com`), modeled after GitHub CLI (`gh`).
44
+
45
+ [![PyPI](https://img.shields.io/pypi/v/pygitcode)](https://pypi.org/project/pygitcode/)
46
+ [![Python](https://img.shields.io/pypi/pyversions/pygitcode)](https://pypi.org/project/pygitcode/)
47
+ [![License](https://img.shields.io/pypi/l/pygitcode)](https://github.com/codeasier/gitcode-cli/blob/main/LICENSE)
48
+
49
+ English | [中文](README.zh-CN.md)
50
+
51
+ For agent-assisted setup, usage, upgrades, or troubleshooting, read [docs/en/USING_WITH_AGENT.md](docs/en/USING_WITH_AGENT.md) first.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install pygitcode
57
+ ```
58
+
59
+ This exposes both `gc` and `gitcode` commands in your shell.
60
+
61
+ ## Quick Start
62
+
63
+ If you already know GitHub CLI (`gh`), you can start with the same familiar flows:
64
+
65
+ ```bash
66
+ gc auth login
67
+ gc issue list
68
+ gc issue view 42
69
+ gc pr list
70
+ gc pr create --fill
71
+ gc pr merge 42 --squash
72
+ ```
73
+
74
+ Use `gitcode` instead of `gc` on Windows PowerShell because `gc` is a built-in alias for `Get-Content`.
75
+
76
+ ## Documentation
77
+
78
+ - [Documentation index](docs/en/index.md)
79
+ - [Development guide](docs/en/development.md)
80
+ - [Compatibility notes](docs/en/gh-compatibility.md)
81
+
82
+ ## Repository Selection
83
+
84
+ Commands operate on the current Git repository by default. You can also select a repository explicitly:
85
+
86
+ ```bash
87
+ gc issue list -R owner/repo
88
+ gc pr list -R owner/repo
89
+ ```
90
+
91
+ ## Authentication
92
+
93
+ `gc` resolves tokens in this order:
94
+
95
+ 1. `--token` option
96
+ 2. `GC_TOKEN` environment variable
97
+ 3. `~/.config/gc/config.json`, written by `gc auth login`
98
+
99
+ Useful commands:
100
+
101
+ ```bash
102
+ gc auth login
103
+ gc auth status
104
+ gc auth token
105
+ gc auth logout
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,70 @@
1
+ # pygitcode
2
+
3
+ > A CLI tool for [GitCode](https://gitcode.com/) (`api.gitcode.com`), modeled after GitHub CLI (`gh`).
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/pygitcode)](https://pypi.org/project/pygitcode/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/pygitcode)](https://pypi.org/project/pygitcode/)
7
+ [![License](https://img.shields.io/pypi/l/pygitcode)](https://github.com/codeasier/gitcode-cli/blob/main/LICENSE)
8
+
9
+ English | [中文](README.zh-CN.md)
10
+
11
+ For agent-assisted setup, usage, upgrades, or troubleshooting, read [docs/en/USING_WITH_AGENT.md](docs/en/USING_WITH_AGENT.md) first.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install pygitcode
17
+ ```
18
+
19
+ This exposes both `gc` and `gitcode` commands in your shell.
20
+
21
+ ## Quick Start
22
+
23
+ If you already know GitHub CLI (`gh`), you can start with the same familiar flows:
24
+
25
+ ```bash
26
+ gc auth login
27
+ gc issue list
28
+ gc issue view 42
29
+ gc pr list
30
+ gc pr create --fill
31
+ gc pr merge 42 --squash
32
+ ```
33
+
34
+ Use `gitcode` instead of `gc` on Windows PowerShell because `gc` is a built-in alias for `Get-Content`.
35
+
36
+ ## Documentation
37
+
38
+ - [Documentation index](docs/en/index.md)
39
+ - [Development guide](docs/en/development.md)
40
+ - [Compatibility notes](docs/en/gh-compatibility.md)
41
+
42
+ ## Repository Selection
43
+
44
+ Commands operate on the current Git repository by default. You can also select a repository explicitly:
45
+
46
+ ```bash
47
+ gc issue list -R owner/repo
48
+ gc pr list -R owner/repo
49
+ ```
50
+
51
+ ## Authentication
52
+
53
+ `gc` resolves tokens in this order:
54
+
55
+ 1. `--token` option
56
+ 2. `GC_TOKEN` environment variable
57
+ 3. `~/.config/gc/config.json`, written by `gc auth login`
58
+
59
+ Useful commands:
60
+
61
+ ```bash
62
+ gc auth login
63
+ gc auth status
64
+ gc auth token
65
+ gc auth logout
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
@@ -46,10 +46,10 @@ dev = [
46
46
  ]
47
47
 
48
48
  [project.urls]
49
- Homepage = "https://github.com/yourusername/pygitcode"
50
- Documentation = "https://github.com/yourusername/pygitcode#readme"
51
- Repository = "https://github.com/yourusername/pygitcode.git"
52
- Issues = "https://github.com/yourusername/pygitcode/issues"
49
+ Homepage = "https://github.com/codeasier/gitcode-cli"
50
+ Documentation = "https://github.com/codeasier/gitcode-cli#readme"
51
+ Repository = "https://github.com/codeasier/gitcode-cli.git"
52
+ Issues = "https://github.com/codeasier/gitcode-cli/issues"
53
53
 
54
54
  [project.scripts]
55
55
  gc = "gitcode_cli.cli:main"
@@ -95,6 +95,7 @@ select = [
95
95
  ignore = [
96
96
  "PLR2004", # Magic value used in comparison
97
97
  "PLR0913", # Too many arguments in function definition (common for Click commands and API methods)
98
+ "PLR0917", # Too many positional arguments (common for Click commands and API methods)
98
99
  ]
99
100
 
100
101
  [tool.ruff.lint.per-file-ignores]
@@ -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
+ )