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.
- pygitcode-0.1.4/PKG-INFO +110 -0
- pygitcode-0.1.4/README.md +70 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/pyproject.toml +5 -4
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/__init__.py +1 -1
- pygitcode-0.1.4/src/gitcode_cli/adapters/__init__.py +7 -0
- pygitcode-0.1.4/src/gitcode_cli/adapters/base.py +14 -0
- pygitcode-0.1.4/src/gitcode_cli/adapters/capabilities.py +36 -0
- pygitcode-0.1.4/src/gitcode_cli/adapters/issues.py +373 -0
- pygitcode-0.1.4/src/gitcode_cli/adapters/pulls.py +198 -0
- pygitcode-0.1.4/src/gitcode_cli/cli.py +113 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/cli_compat.py +2 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/client.py +23 -9
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/commands/auth.py +20 -6
- pygitcode-0.1.4/src/gitcode_cli/commands/compat.py +89 -0
- pygitcode-0.1.4/src/gitcode_cli/commands/issue.py +778 -0
- pygitcode-0.1.4/src/gitcode_cli/commands/pr.py +999 -0
- pygitcode-0.1.4/src/gitcode_cli/compat/__init__.py +10 -0
- pygitcode-0.1.4/src/gitcode_cli/compat/_types.py +48 -0
- pygitcode-0.1.4/src/gitcode_cli/compat/entries.py +459 -0
- pygitcode-0.1.4/src/gitcode_cli/compat/registry.py +146 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/errors.py +4 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/formatters.py +62 -40
- pygitcode-0.1.4/src/gitcode_cli/helptext.py +220 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/__init__.py +2 -1
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/issues.py +17 -2
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/services/pulls.py +20 -1
- pygitcode-0.1.4/src/gitcode_cli/services/users.py +13 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/utils.py +20 -4
- pygitcode-0.1.4/src/pygitcode.egg-info/PKG-INFO +110 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/SOURCES.txt +12 -0
- pygitcode-0.1.2a0/PKG-INFO +0 -283
- pygitcode-0.1.2a0/README.md +0 -243
- pygitcode-0.1.2a0/src/gitcode_cli/cli.py +0 -66
- pygitcode-0.1.2a0/src/gitcode_cli/commands/issue.py +0 -365
- pygitcode-0.1.2a0/src/gitcode_cli/commands/pr.py +0 -570
- pygitcode-0.1.2a0/src/pygitcode.egg-info/PKG-INFO +0 -283
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/LICENSE +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/setup.cfg +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/commands/__init__.py +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/config.py +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/context.py +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/gitcode_cli/repo.py +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/dependency_links.txt +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/entry_points.txt +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/requires.txt +0 -0
- {pygitcode-0.1.2a0 → pygitcode-0.1.4}/src/pygitcode.egg-info/top_level.txt +0 -0
pygitcode-0.1.4/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pygitcode/)
|
|
46
|
+
[](https://pypi.org/project/pygitcode/)
|
|
47
|
+
[](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
|
+
[](https://pypi.org/project/pygitcode/)
|
|
6
|
+
[](https://pypi.org/project/pygitcode/)
|
|
7
|
+
[](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/
|
|
50
|
-
Documentation = "https://github.com/
|
|
51
|
-
Repository = "https://github.com/
|
|
52
|
-
Issues = "https://github.com/
|
|
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]
|
|
@@ -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
|
+
)
|