pygitcode 0.1.3__tar.gz → 0.1.5__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.3 → pygitcode-0.1.5}/PKG-INFO +12 -1
- {pygitcode-0.1.3 → pygitcode-0.1.5}/README.md +11 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/pyproject.toml +2 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/__init__.py +1 -1
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/adapters/capabilities.py +4 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/adapters/issues.py +29 -12
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/adapters/pulls.py +0 -2
- pygitcode-0.1.5/src/gitcode_cli/audit.py +442 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/cli.py +17 -10
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/commands/auth.py +17 -3
- pygitcode-0.1.5/src/gitcode_cli/commands/compat.py +89 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/commands/pr.py +268 -24
- pygitcode-0.1.5/src/gitcode_cli/commands/setup.py +250 -0
- pygitcode-0.1.5/src/gitcode_cli/compat/__init__.py +10 -0
- pygitcode-0.1.5/src/gitcode_cli/compat/_types.py +48 -0
- pygitcode-0.1.5/src/gitcode_cli/compat/entries.py +459 -0
- pygitcode-0.1.5/src/gitcode_cli/compat/registry.py +146 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/formatters.py +23 -0
- pygitcode-0.1.5/src/gitcode_cli/gh_proxy.py +216 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/helptext.py +11 -4
- pygitcode-0.1.5/src/gitcode_cli/repo.py +58 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/services/pulls.py +30 -1
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/utils.py +1 -1
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/PKG-INFO +12 -1
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/SOURCES.txt +8 -0
- pygitcode-0.1.3/src/gitcode_cli/repo.py +0 -43
- {pygitcode-0.1.3 → pygitcode-0.1.5}/LICENSE +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/setup.cfg +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/adapters/__init__.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/adapters/base.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/cli_compat.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/client.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/commands/__init__.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/commands/issue.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/config.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/context.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/errors.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/services/__init__.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/services/issues.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/gitcode_cli/services/users.py +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/dependency_links.txt +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/entry_points.txt +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/requires.txt +0 -0
- {pygitcode-0.1.3 → pygitcode-0.1.5}/src/pygitcode.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygitcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A CLI tool for GitCode (api.gitcode.com), modeled after GitHub CLI.
|
|
5
5
|
Author-email: codeasier <825770651@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,6 +58,14 @@ pip install pygitcode
|
|
|
58
58
|
|
|
59
59
|
This exposes both `gc` and `gitcode` commands in your shell.
|
|
60
60
|
|
|
61
|
+
To optionally route existing `gh` commands between GitCode and GitHub, install and configure the managed proxy:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
gc setup gh-proxy --configure
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The command installs the shim, adds a managed shell `PATH` block, and registers OpenCode instructions that teach agents to try the dispatcher before API fallbacks. Restart OpenCode afterward. The proxy sends GitCode repositories to `gc`, sends GitHub or unknown contexts to the native `gh`, and refuses unsupported commands when the target is GitCode. See [the compatibility notes](docs/en/gh-compatibility.md#optional-gh-proxy) for routing controls and uninstall instructions.
|
|
68
|
+
|
|
61
69
|
## Quick Start
|
|
62
70
|
|
|
63
71
|
If you already know GitHub CLI (`gh`), you can start with the same familiar flows:
|
|
@@ -68,11 +76,14 @@ gc issue list
|
|
|
68
76
|
gc issue view 42
|
|
69
77
|
gc pr list
|
|
70
78
|
gc pr create --fill
|
|
79
|
+
gc pr audit
|
|
71
80
|
gc pr merge 42 --squash
|
|
72
81
|
```
|
|
73
82
|
|
|
74
83
|
Use `gitcode` instead of `gc` on Windows PowerShell because `gc` is a built-in alias for `Get-Content`.
|
|
75
84
|
|
|
85
|
+
`gc pr audit` prints a reason for every failed R1-R4 merge-readiness rule. List-mode errors other than HTTP 401 are recorded per PR and exit 0 unless `--fail-exit` is set. HTTP 401 aborts the remaining list and exits 1, regardless of this flag.
|
|
86
|
+
|
|
76
87
|
## Documentation
|
|
77
88
|
|
|
78
89
|
- [Documentation index](docs/en/index.md)
|
|
@@ -18,6 +18,14 @@ pip install pygitcode
|
|
|
18
18
|
|
|
19
19
|
This exposes both `gc` and `gitcode` commands in your shell.
|
|
20
20
|
|
|
21
|
+
To optionally route existing `gh` commands between GitCode and GitHub, install and configure the managed proxy:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gc setup gh-proxy --configure
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The command installs the shim, adds a managed shell `PATH` block, and registers OpenCode instructions that teach agents to try the dispatcher before API fallbacks. Restart OpenCode afterward. The proxy sends GitCode repositories to `gc`, sends GitHub or unknown contexts to the native `gh`, and refuses unsupported commands when the target is GitCode. See [the compatibility notes](docs/en/gh-compatibility.md#optional-gh-proxy) for routing controls and uninstall instructions.
|
|
28
|
+
|
|
21
29
|
## Quick Start
|
|
22
30
|
|
|
23
31
|
If you already know GitHub CLI (`gh`), you can start with the same familiar flows:
|
|
@@ -28,11 +36,14 @@ gc issue list
|
|
|
28
36
|
gc issue view 42
|
|
29
37
|
gc pr list
|
|
30
38
|
gc pr create --fill
|
|
39
|
+
gc pr audit
|
|
31
40
|
gc pr merge 42 --squash
|
|
32
41
|
```
|
|
33
42
|
|
|
34
43
|
Use `gitcode` instead of `gc` on Windows PowerShell because `gc` is a built-in alias for `Get-Content`.
|
|
35
44
|
|
|
45
|
+
`gc pr audit` prints a reason for every failed R1-R4 merge-readiness rule. List-mode errors other than HTTP 401 are recorded per PR and exit 0 unless `--fail-exit` is set. HTTP 401 aborts the remaining list and exits 1, regardless of this flag.
|
|
46
|
+
|
|
36
47
|
## Documentation
|
|
37
48
|
|
|
38
49
|
- [Documentation index](docs/en/index.md)
|
|
@@ -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]
|
|
@@ -125,6 +126,7 @@ reportUnknownMemberType = false
|
|
|
125
126
|
reportUnknownVariableType = false
|
|
126
127
|
reportUnknownArgumentType = false
|
|
127
128
|
reportUnknownParameterType = false
|
|
129
|
+
reportFunctionMemberAccess = false
|
|
128
130
|
reportOptionalSubscript = false
|
|
129
131
|
reportOptionalMemberAccess = false
|
|
130
132
|
reportOptionalIterable = false
|
|
@@ -17,6 +17,10 @@ CAPABILITY_MESSAGES = {
|
|
|
17
17
|
),
|
|
18
18
|
"PR_MERGE_AUTHOR_EMAIL": "GitCode merge API does not support --author-email.",
|
|
19
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
|
+
),
|
|
20
24
|
"PR_REVIEW_REQUEST_CHANGES": (
|
|
21
25
|
"GitCode review API does not support request-changes reviews; the pull request comment was posted instead."
|
|
22
26
|
),
|
|
@@ -177,18 +177,35 @@ class IssueAdapter:
|
|
|
177
177
|
if not current_login:
|
|
178
178
|
raise click.ClickException("failed to resolve @me: current user has no login")
|
|
179
179
|
author = current_login
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
|
|
180
|
+
params = {
|
|
181
|
+
"state": state,
|
|
182
|
+
"labels": _normalize_multi_values(labels),
|
|
183
|
+
"creator": author,
|
|
184
|
+
"assignee": assignee,
|
|
185
|
+
"milestone": milestone,
|
|
186
|
+
"mention": mention,
|
|
187
|
+
"search": search,
|
|
188
|
+
}
|
|
189
|
+
needs_and_filter = bool(labels) and len(labels) > 1
|
|
190
|
+
per_page = 100 if limit is None else min(limit, 100)
|
|
191
|
+
items: list[Any] = []
|
|
192
|
+
page = 1
|
|
193
|
+
while True:
|
|
194
|
+
page_items = _as_list(self.service.list(owner, repo, **params, page=page, per_page=per_page))
|
|
195
|
+
if not page_items:
|
|
196
|
+
break
|
|
197
|
+
items.extend(page_items)
|
|
198
|
+
if len(page_items) < per_page:
|
|
199
|
+
break
|
|
200
|
+
if limit is not None:
|
|
201
|
+
if needs_and_filter:
|
|
202
|
+
matched = sum(1 for item in items if _matches_all_labels(item, labels))
|
|
203
|
+
else:
|
|
204
|
+
matched = len(items)
|
|
205
|
+
if matched >= limit:
|
|
206
|
+
break
|
|
207
|
+
page += 1
|
|
208
|
+
if needs_and_filter:
|
|
192
209
|
items = [item for item in items if _matches_all_labels(item, labels)]
|
|
193
210
|
if limit is not None:
|
|
194
211
|
return items[:limit]
|
|
@@ -161,7 +161,6 @@ class PullRequestAdapter:
|
|
|
161
161
|
*,
|
|
162
162
|
title: str | None,
|
|
163
163
|
body: str | None,
|
|
164
|
-
base: str | None,
|
|
165
164
|
add_assignee: str | None,
|
|
166
165
|
add_label: str | None,
|
|
167
166
|
add_reviewer: str | None,
|
|
@@ -176,7 +175,6 @@ class PullRequestAdapter:
|
|
|
176
175
|
for k, v in {
|
|
177
176
|
"title": title,
|
|
178
177
|
"body": body,
|
|
179
|
-
"base": base,
|
|
180
178
|
"assignee": add_assignee,
|
|
181
179
|
"labels": add_label,
|
|
182
180
|
"reviewer": add_reviewer,
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import APIError
|
|
8
|
+
from .services import PullRequestService
|
|
9
|
+
|
|
10
|
+
# Directory/basename patterns are case-insensitive; PascalCase TestX stays sensitive
|
|
11
|
+
# so docs/testing-guide.md and testdata.json are not treated as tests.
|
|
12
|
+
TEST_PATH_RE = re.compile(
|
|
13
|
+
r"(?i:(^|/)(test|tests|__tests__|spec|__mocks__)/|(^|/)test_|_test\.|\.test\.|\.spec\.|(^|/)test\.cpp)"
|
|
14
|
+
r"|(^|/)Test[A-Z]"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
AUDIT_JSON_FIELDS = [
|
|
18
|
+
"number",
|
|
19
|
+
"title",
|
|
20
|
+
"url",
|
|
21
|
+
"overall",
|
|
22
|
+
"verdict",
|
|
23
|
+
"loc",
|
|
24
|
+
"r1",
|
|
25
|
+
"r2",
|
|
26
|
+
"r3",
|
|
27
|
+
"r4",
|
|
28
|
+
"failedRules",
|
|
29
|
+
"reasons",
|
|
30
|
+
"rules",
|
|
31
|
+
"milestone",
|
|
32
|
+
"issues",
|
|
33
|
+
"unresolved",
|
|
34
|
+
"reviewCnt",
|
|
35
|
+
"hasTest",
|
|
36
|
+
"hasMinutes",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AuditThresholds:
|
|
42
|
+
th1: int = 100
|
|
43
|
+
th2: int = 1000
|
|
44
|
+
minutes_keyword: str = "评审纪要"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _as_list(value: Any) -> list[Any]:
|
|
48
|
+
if isinstance(value, list):
|
|
49
|
+
return value
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_text(value: Any) -> str:
|
|
54
|
+
return value if isinstance(value, str) else ("" if value is None else str(value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _optional_int(value: Any) -> int | None:
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return int(value)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_discussion_passed(pr: dict[str, Any]) -> bool:
|
|
67
|
+
mergeable_state = pr.get("mergeable_state")
|
|
68
|
+
if not isinstance(mergeable_state, dict):
|
|
69
|
+
return True
|
|
70
|
+
resolve_pass = mergeable_state.get("resolve_discussion_passed", True)
|
|
71
|
+
if resolve_pass is None:
|
|
72
|
+
return True
|
|
73
|
+
return bool(resolve_pass)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mergeable_state_known(pr: dict[str, Any]) -> bool:
|
|
77
|
+
mergeable_state = pr.get("mergeable_state")
|
|
78
|
+
if not isinstance(mergeable_state, dict):
|
|
79
|
+
return False
|
|
80
|
+
return mergeable_state.get("resolve_discussion_passed") is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def loc_from_list_item(item: dict[str, Any] | None) -> int | None:
|
|
84
|
+
if not item:
|
|
85
|
+
return None
|
|
86
|
+
added = item.get("added_lines")
|
|
87
|
+
removed = item.get("removed_lines")
|
|
88
|
+
if isinstance(added, int) and isinstance(removed, int):
|
|
89
|
+
return added + removed
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_DIFF_FILE_HEADER_RE = re.compile(r"^(---|\+\+\+) (?:[ab]/|/dev/null|\")")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def loc_from_diff(diff_text: str) -> int:
|
|
97
|
+
loc = 0
|
|
98
|
+
skipping_binary = False
|
|
99
|
+
in_hunk = False
|
|
100
|
+
for line in diff_text.splitlines():
|
|
101
|
+
if line.startswith("diff --git "):
|
|
102
|
+
skipping_binary = False
|
|
103
|
+
in_hunk = False
|
|
104
|
+
continue
|
|
105
|
+
if line.startswith("GIT binary patch"):
|
|
106
|
+
skipping_binary = True
|
|
107
|
+
continue
|
|
108
|
+
if skipping_binary:
|
|
109
|
+
continue
|
|
110
|
+
if line.startswith("@@"):
|
|
111
|
+
in_hunk = True
|
|
112
|
+
continue
|
|
113
|
+
if not in_hunk and _DIFF_FILE_HEADER_RE.match(line):
|
|
114
|
+
continue
|
|
115
|
+
if line.startswith("+") or line.startswith("-"):
|
|
116
|
+
loc += 1
|
|
117
|
+
return loc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_DIFF_B_PATH_RE = re.compile(r' (?:b/(\S+)|"b/([^"]+)")$')
|
|
121
|
+
_DIFF_A_PATH_RE = re.compile(r' (?:a/(\S+)|"a/([^"]+)")')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _path_from_diff_header(line: str) -> str | None:
|
|
125
|
+
if not line.startswith("diff --git "):
|
|
126
|
+
return None
|
|
127
|
+
rest = line[len("diff --git") :]
|
|
128
|
+
match = _DIFF_B_PATH_RE.search(rest) or _DIFF_A_PATH_RE.search(rest)
|
|
129
|
+
if not match:
|
|
130
|
+
return None
|
|
131
|
+
return match.group(1) or match.group(2)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_plus_dev_null_header(line: str) -> bool:
|
|
135
|
+
return line.startswith("+++ /dev/null") or line.startswith('+++ "/dev/null"')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def paths_from_diff(diff_text: str) -> list[str]:
|
|
139
|
+
paths: list[str] = []
|
|
140
|
+
current: str | None = None
|
|
141
|
+
deleted = False
|
|
142
|
+
in_hunk = False
|
|
143
|
+
seen_minus_header = False
|
|
144
|
+
for line in diff_text.splitlines():
|
|
145
|
+
if line.startswith("diff --git "):
|
|
146
|
+
if current and not deleted:
|
|
147
|
+
paths.append(current)
|
|
148
|
+
current = _path_from_diff_header(line)
|
|
149
|
+
deleted = False
|
|
150
|
+
in_hunk = False
|
|
151
|
+
seen_minus_header = False
|
|
152
|
+
continue
|
|
153
|
+
if line.startswith("@@"):
|
|
154
|
+
in_hunk = True
|
|
155
|
+
continue
|
|
156
|
+
if line.startswith("deleted file mode"):
|
|
157
|
+
deleted = True
|
|
158
|
+
continue
|
|
159
|
+
if line.startswith("--- "):
|
|
160
|
+
seen_minus_header = True
|
|
161
|
+
continue
|
|
162
|
+
if not in_hunk and seen_minus_header and _is_plus_dev_null_header(line):
|
|
163
|
+
deleted = True
|
|
164
|
+
if current and not deleted:
|
|
165
|
+
paths.append(current)
|
|
166
|
+
return paths
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _current_file_path(item: dict[str, Any]) -> str | None:
|
|
170
|
+
status = str(item.get("status") or item.get("change_type") or "").lower()
|
|
171
|
+
if status in {"removed", "deleted", "delete"}:
|
|
172
|
+
return None
|
|
173
|
+
patch = item.get("patch")
|
|
174
|
+
if isinstance(patch, dict):
|
|
175
|
+
new_path = patch.get("new_path")
|
|
176
|
+
if new_path and str(new_path) not in {"/dev/null", "dev/null"}:
|
|
177
|
+
return str(new_path)
|
|
178
|
+
if patch.get("old_path"):
|
|
179
|
+
return None
|
|
180
|
+
filename = item.get("filename")
|
|
181
|
+
return str(filename) if filename else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def loc_and_paths_from_files(files: list[Any]) -> tuple[int | None, list[str]]:
|
|
185
|
+
paths: list[str] = []
|
|
186
|
+
loc = 0
|
|
187
|
+
have_counts = False
|
|
188
|
+
for item in files:
|
|
189
|
+
if not isinstance(item, dict):
|
|
190
|
+
continue
|
|
191
|
+
path = _current_file_path(item)
|
|
192
|
+
if path:
|
|
193
|
+
paths.append(path)
|
|
194
|
+
if "additions" not in item and "deletions" not in item:
|
|
195
|
+
continue
|
|
196
|
+
additions = _optional_int(item["additions"]) if "additions" in item else None
|
|
197
|
+
deletions = _optional_int(item["deletions"]) if "deletions" in item else None
|
|
198
|
+
if additions is None and deletions is None:
|
|
199
|
+
continue
|
|
200
|
+
loc += (additions or 0) + (deletions or 0)
|
|
201
|
+
have_counts = True
|
|
202
|
+
return (loc if have_counts else None), paths
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _label_names(labels: Any) -> list[str]:
|
|
206
|
+
names: list[str] = []
|
|
207
|
+
for label in _as_list(labels):
|
|
208
|
+
if isinstance(label, dict):
|
|
209
|
+
names.append(str(label.get("name") or ""))
|
|
210
|
+
elif label is not None:
|
|
211
|
+
names.append(str(label))
|
|
212
|
+
return names
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _issue_numbers(issues: list[Any]) -> list[str]:
|
|
216
|
+
numbers: list[str] = []
|
|
217
|
+
for issue in issues:
|
|
218
|
+
if not isinstance(issue, dict):
|
|
219
|
+
continue
|
|
220
|
+
number = issue.get("number")
|
|
221
|
+
if number is not None:
|
|
222
|
+
numbers.append(str(number))
|
|
223
|
+
return numbers
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _milestone_title(milestone: Any) -> str | None:
|
|
227
|
+
if isinstance(milestone, dict):
|
|
228
|
+
title = milestone.get("title")
|
|
229
|
+
return str(title) if title else None
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def evaluate_audit(
|
|
234
|
+
*,
|
|
235
|
+
pr: dict[str, Any],
|
|
236
|
+
issues: list[Any],
|
|
237
|
+
comments: list[Any],
|
|
238
|
+
loc: int | None,
|
|
239
|
+
file_paths: list[str] | None,
|
|
240
|
+
thresholds: AuditThresholds | None = None,
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
thresholds = thresholds or AuditThresholds()
|
|
243
|
+
keyword = thresholds.minutes_keyword
|
|
244
|
+
|
|
245
|
+
issue_numbers = _issue_numbers(issues)
|
|
246
|
+
milestone = _milestone_title(pr.get("milestone"))
|
|
247
|
+
r1 = milestone is not None or bool(issue_numbers)
|
|
248
|
+
r1_reasons: list[str] = []
|
|
249
|
+
if not r1:
|
|
250
|
+
r1_reasons.append("R1: no milestone and no officially linked issues")
|
|
251
|
+
|
|
252
|
+
unresolved = [
|
|
253
|
+
comment
|
|
254
|
+
for comment in comments
|
|
255
|
+
if isinstance(comment, dict)
|
|
256
|
+
and comment.get("comment_type") == "diff_comment"
|
|
257
|
+
and comment.get("resolved") is False
|
|
258
|
+
]
|
|
259
|
+
resolve_pass = _resolve_discussion_passed(pr)
|
|
260
|
+
r2 = len(unresolved) == 0 and resolve_pass
|
|
261
|
+
r2_reasons: list[str] = []
|
|
262
|
+
if not r2:
|
|
263
|
+
parts: list[str] = []
|
|
264
|
+
if unresolved:
|
|
265
|
+
parts.append(f"{len(unresolved)} unresolved diff_comment(s)")
|
|
266
|
+
if not resolve_pass:
|
|
267
|
+
parts.append("mergeable_state.resolve_discussion_passed=false")
|
|
268
|
+
r2_reasons.append("R2: " + "; ".join(parts))
|
|
269
|
+
elif not _mergeable_state_known(pr):
|
|
270
|
+
r2_reasons.append("R2: merge state unknown")
|
|
271
|
+
|
|
272
|
+
review_cnt = sum(
|
|
273
|
+
1 for comment in comments if isinstance(comment, dict) and comment.get("comment_type") == "diff_comment"
|
|
274
|
+
)
|
|
275
|
+
has_test = None if file_paths is None else any(TEST_PATH_RE.search(_as_text(path)) for path in file_paths)
|
|
276
|
+
r3_reasons: list[str] = []
|
|
277
|
+
if loc is None:
|
|
278
|
+
r3 = False
|
|
279
|
+
r3_reasons.append("R3: loc unavailable")
|
|
280
|
+
else:
|
|
281
|
+
r3 = loc <= thresholds.th1 or review_cnt > 0 or bool(has_test)
|
|
282
|
+
if not r3:
|
|
283
|
+
r3_reasons.append(f"R3: loc {loc} > {thresholds.th1}, no diff_comment, and no test-path files")
|
|
284
|
+
|
|
285
|
+
has_minutes = (
|
|
286
|
+
keyword in _as_text(pr.get("body"))
|
|
287
|
+
or any(keyword in _as_text(comment.get("body") if isinstance(comment, dict) else "") for comment in comments)
|
|
288
|
+
or any(keyword in name for name in _label_names(pr.get("labels")))
|
|
289
|
+
)
|
|
290
|
+
r4_reasons: list[str] = []
|
|
291
|
+
if loc is None:
|
|
292
|
+
r4 = False
|
|
293
|
+
r4_reasons.append("R4: loc unavailable")
|
|
294
|
+
else:
|
|
295
|
+
r4 = loc <= thresholds.th2 or has_minutes
|
|
296
|
+
if not r4:
|
|
297
|
+
r4_reasons.append(
|
|
298
|
+
f"R4: loc {loc} > {thresholds.th2}, and '{keyword}' not found in body, comments, or labels"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
overall = r1 and r2 and r3 and r4
|
|
302
|
+
reasons = [*r1_reasons, *r2_reasons, *r3_reasons, *r4_reasons]
|
|
303
|
+
failed_rules = [rule for rule, passed in (("R1", r1), ("R2", r2), ("R3", r3), ("R4", r4)) if not passed]
|
|
304
|
+
return {
|
|
305
|
+
"number": pr.get("number"),
|
|
306
|
+
"title": pr.get("title") or "",
|
|
307
|
+
"url": pr.get("html_url") or pr.get("url"),
|
|
308
|
+
"overall": overall,
|
|
309
|
+
"verdict": "PASS" if overall else "FAIL",
|
|
310
|
+
"loc": loc,
|
|
311
|
+
"r1": r1,
|
|
312
|
+
"r2": r2,
|
|
313
|
+
"r3": r3,
|
|
314
|
+
"r4": r4,
|
|
315
|
+
"failedRules": failed_rules,
|
|
316
|
+
"reasons": reasons,
|
|
317
|
+
"rules": {
|
|
318
|
+
"R1": {"pass": r1, "reasons": r1_reasons},
|
|
319
|
+
"R2": {"pass": r2, "reasons": r2_reasons},
|
|
320
|
+
"R3": {"pass": r3, "reasons": r3_reasons},
|
|
321
|
+
"R4": {"pass": r4, "reasons": r4_reasons},
|
|
322
|
+
},
|
|
323
|
+
"milestone": milestone,
|
|
324
|
+
"issues": issue_numbers,
|
|
325
|
+
"unresolved": len(unresolved),
|
|
326
|
+
"reviewCnt": review_cnt,
|
|
327
|
+
"hasTest": has_test,
|
|
328
|
+
"hasMinutes": has_minutes,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def audit_error_result(
|
|
333
|
+
number: int,
|
|
334
|
+
exc: BaseException,
|
|
335
|
+
listed: dict[str, Any] | None = None,
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
listed = listed or {}
|
|
338
|
+
reason = f"audit error: {exc}"
|
|
339
|
+
return {
|
|
340
|
+
"number": listed.get("number", number),
|
|
341
|
+
"title": listed.get("title") or "",
|
|
342
|
+
"url": listed.get("html_url") or listed.get("url"),
|
|
343
|
+
"overall": False,
|
|
344
|
+
"verdict": "FAIL",
|
|
345
|
+
"loc": None,
|
|
346
|
+
"r1": False,
|
|
347
|
+
"r2": False,
|
|
348
|
+
"r3": False,
|
|
349
|
+
"r4": False,
|
|
350
|
+
"failedRules": ["R1", "R2", "R3", "R4"],
|
|
351
|
+
"reasons": [reason],
|
|
352
|
+
"rules": {
|
|
353
|
+
"R1": {"pass": False, "reasons": [reason]},
|
|
354
|
+
"R2": {"pass": False, "reasons": [reason]},
|
|
355
|
+
"R3": {"pass": False, "reasons": [reason]},
|
|
356
|
+
"R4": {"pass": False, "reasons": [reason]},
|
|
357
|
+
},
|
|
358
|
+
"milestone": None,
|
|
359
|
+
"issues": [],
|
|
360
|
+
"unresolved": 0,
|
|
361
|
+
"reviewCnt": 0,
|
|
362
|
+
"hasTest": None,
|
|
363
|
+
"hasMinutes": False,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def is_fatal_audit_error(exc: BaseException) -> bool:
|
|
368
|
+
# HTTP 401 is raised as APIError(..., 401); the client never raises AuthError.
|
|
369
|
+
return isinstance(exc, APIError) and exc.status_code == 401
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def resolve_loc_and_paths(
|
|
373
|
+
service: PullRequestService,
|
|
374
|
+
owner: str,
|
|
375
|
+
repo: str,
|
|
376
|
+
number: int,
|
|
377
|
+
listed: dict[str, Any] | None,
|
|
378
|
+
comments: list[Any],
|
|
379
|
+
thresholds: AuditThresholds,
|
|
380
|
+
loc_item: dict[str, Any] | None = None,
|
|
381
|
+
) -> tuple[int | None, list[str] | None]:
|
|
382
|
+
loc = loc_from_list_item(listed)
|
|
383
|
+
if loc is None:
|
|
384
|
+
loc = loc_from_list_item(loc_item)
|
|
385
|
+
paths: list[str] | None = None
|
|
386
|
+
files_have_counts = False
|
|
387
|
+
needs_paths = loc is None or (
|
|
388
|
+
loc > thresholds.th1
|
|
389
|
+
and not any(isinstance(comment, dict) and comment.get("comment_type") == "diff_comment" for comment in comments)
|
|
390
|
+
)
|
|
391
|
+
if loc is None or needs_paths:
|
|
392
|
+
files = _as_list(service.list_files(owner, repo, number))
|
|
393
|
+
file_loc, paths = loc_and_paths_from_files(files)
|
|
394
|
+
files_have_counts = file_loc is not None
|
|
395
|
+
if loc is None:
|
|
396
|
+
loc = file_loc
|
|
397
|
+
if loc is None or (needs_paths and not paths and not files_have_counts):
|
|
398
|
+
diff_text = service.diff(owner, repo, number)
|
|
399
|
+
if loc is None:
|
|
400
|
+
loc = loc_from_diff(diff_text) if diff_text else None
|
|
401
|
+
if not paths and diff_text:
|
|
402
|
+
paths = paths_from_diff(diff_text)
|
|
403
|
+
return loc, paths
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def audit_pull_request(
|
|
407
|
+
service: PullRequestService,
|
|
408
|
+
owner: str,
|
|
409
|
+
repo: str,
|
|
410
|
+
number: int,
|
|
411
|
+
listed: dict[str, Any] | None = None,
|
|
412
|
+
thresholds: AuditThresholds | None = None,
|
|
413
|
+
) -> dict[str, Any]:
|
|
414
|
+
thresholds = thresholds or AuditThresholds()
|
|
415
|
+
detail = service.get(owner, repo, number)
|
|
416
|
+
if not isinstance(detail, dict):
|
|
417
|
+
detail = {}
|
|
418
|
+
if listed:
|
|
419
|
+
if detail.get("milestone") is None and listed.get("milestone") is not None:
|
|
420
|
+
detail = {**detail, "milestone": listed.get("milestone")}
|
|
421
|
+
if not detail.get("labels") and listed.get("labels"):
|
|
422
|
+
detail = {**detail, "labels": listed.get("labels")}
|
|
423
|
+
if not detail.get("body") and listed.get("body"):
|
|
424
|
+
detail = {**detail, "body": listed.get("body")}
|
|
425
|
+
if detail.get("number") is None:
|
|
426
|
+
detail = {**detail, "number": listed.get("number")}
|
|
427
|
+
if not detail.get("title") and listed.get("title"):
|
|
428
|
+
detail = {**detail, "title": listed.get("title")}
|
|
429
|
+
if not detail.get("html_url") and not detail.get("url"):
|
|
430
|
+
detail = {**detail, "html_url": listed.get("html_url") or listed.get("url")}
|
|
431
|
+
|
|
432
|
+
issues = _as_list(service.list_issues(owner, repo, number))
|
|
433
|
+
comments = _as_list(service.list_comments(owner, repo, number))
|
|
434
|
+
loc, file_paths = resolve_loc_and_paths(service, owner, repo, number, listed, comments, thresholds, loc_item=detail)
|
|
435
|
+
return evaluate_audit(
|
|
436
|
+
pr=detail,
|
|
437
|
+
issues=issues,
|
|
438
|
+
comments=comments,
|
|
439
|
+
loc=loc,
|
|
440
|
+
file_paths=file_paths,
|
|
441
|
+
thresholds=thresholds,
|
|
442
|
+
)
|
|
@@ -9,8 +9,10 @@ from click.shell_completion import get_completion_class
|
|
|
9
9
|
|
|
10
10
|
from . import __version__
|
|
11
11
|
from .commands.auth import auth_group
|
|
12
|
+
from .commands.compat import compat_group
|
|
12
13
|
from .commands.issue import issue_group
|
|
13
14
|
from .commands.pr import pr_group
|
|
15
|
+
from .commands.setup import setup_group
|
|
14
16
|
from .config import get_token
|
|
15
17
|
from .context import AppContext
|
|
16
18
|
from .errors import GCError
|
|
@@ -67,12 +69,13 @@ set_gc_help(
|
|
|
67
69
|
gc_usage="gc <command> <subcommand> [flags]",
|
|
68
70
|
gc_command_sections=[
|
|
69
71
|
("CORE COMMANDS", ["auth", "issue", "pr"]),
|
|
70
|
-
("ADDITIONAL COMMANDS", ["completion", "version"]),
|
|
72
|
+
("ADDITIONAL COMMANDS", ["compat", "setup", "completion", "version"]),
|
|
71
73
|
],
|
|
72
74
|
gc_examples=[
|
|
73
75
|
"gc issue create",
|
|
74
76
|
"gc pr list -R owner/repo",
|
|
75
77
|
"gc auth login",
|
|
78
|
+
"gc compat report --stdout",
|
|
76
79
|
],
|
|
77
80
|
gc_learn_more=[
|
|
78
81
|
"Use `gc <command> <subcommand> --help` for more information about a command.",
|
|
@@ -80,17 +83,19 @@ set_gc_help(
|
|
|
80
83
|
)
|
|
81
84
|
|
|
82
85
|
|
|
83
|
-
auth_group.short_help = "Authenticate gc with GitCode"
|
|
84
|
-
issue_group.short_help = "Manage issues"
|
|
85
|
-
pr_group.short_help = "Manage pull requests"
|
|
86
|
+
auth_group.short_help = "Authenticate gc with GitCode" # type: ignore[attr-defined]
|
|
87
|
+
issue_group.short_help = "Manage issues" # type: ignore[attr-defined]
|
|
88
|
+
pr_group.short_help = "Manage pull requests" # type: ignore[attr-defined]
|
|
89
|
+
compat_group.short_help = "Inspect gc ↔ gh compatibility" # type: ignore[attr-defined]
|
|
90
|
+
setup_group.short_help = "Configure optional gc integrations" # type: ignore[attr-defined]
|
|
86
91
|
|
|
87
92
|
|
|
88
|
-
@main.command("version", short_help="Show gc version", help="Show gc version.")
|
|
93
|
+
@main.command("version", short_help="Show gc version", help="Show gc version.") # type: ignore[attr-defined]
|
|
89
94
|
def version_command() -> None:
|
|
90
95
|
safe_echo(f"gitcode version {_get_version()}")
|
|
91
96
|
|
|
92
97
|
|
|
93
|
-
@main.command("completion", short_help="Generate shell completion scripts", help="Generate shell completion scripts.")
|
|
98
|
+
@main.command("completion", short_help="Generate shell completion scripts", help="Generate shell completion scripts.") # type: ignore[attr-defined]
|
|
94
99
|
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
95
100
|
def completion_command(shell: str) -> None:
|
|
96
101
|
comp_class = get_completion_class(shell)
|
|
@@ -100,10 +105,12 @@ def completion_command(shell: str) -> None:
|
|
|
100
105
|
safe_echo(comp.source())
|
|
101
106
|
|
|
102
107
|
|
|
103
|
-
main.add_command(auth_group)
|
|
104
|
-
main.add_command(issue_group)
|
|
105
|
-
main.add_command(pr_group)
|
|
108
|
+
main.add_command(auth_group) # type: ignore[attr-defined]
|
|
109
|
+
main.add_command(issue_group) # type: ignore[attr-defined]
|
|
110
|
+
main.add_command(pr_group) # type: ignore[attr-defined]
|
|
111
|
+
main.add_command(compat_group) # type: ignore[attr-defined]
|
|
112
|
+
main.add_command(setup_group) # type: ignore[attr-defined]
|
|
106
113
|
|
|
107
114
|
|
|
108
115
|
if __name__ == "__main__": # pragma: no cover
|
|
109
|
-
main()
|
|
116
|
+
main() # type: ignore[call-issue]
|