pygitcode 0.1.4__tar.gz → 0.1.6__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 → pygitcode-0.1.6}/PKG-INFO +12 -1
- {pygitcode-0.1.4 → pygitcode-0.1.6}/README.md +11 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/pyproject.toml +1 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/__init__.py +1 -1
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/adapters/issues.py +29 -12
- pygitcode-0.1.6/src/gitcode_cli/audit.py +442 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/cli.py +5 -2
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/commands/auth.py +17 -3
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/commands/pr.py +216 -27
- pygitcode-0.1.6/src/gitcode_cli/commands/setup.py +250 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/compat/entries.py +1 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/formatters.py +23 -0
- pygitcode-0.1.6/src/gitcode_cli/gh_proxy.py +216 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/helptext.py +11 -4
- pygitcode-0.1.6/src/gitcode_cli/repo.py +58 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/services/pulls.py +33 -18
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/utils.py +1 -1
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/pygitcode.egg-info/PKG-INFO +12 -1
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/pygitcode.egg-info/SOURCES.txt +3 -0
- pygitcode-0.1.4/src/gitcode_cli/repo.py +0 -43
- {pygitcode-0.1.4 → pygitcode-0.1.6}/LICENSE +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/setup.cfg +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/adapters/__init__.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/adapters/base.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/adapters/capabilities.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/adapters/pulls.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/cli_compat.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/client.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/commands/__init__.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/commands/compat.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/commands/issue.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/compat/__init__.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/compat/_types.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/compat/registry.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/config.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/context.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/errors.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/services/__init__.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/services/issues.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/gitcode_cli/services/users.py +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/pygitcode.egg-info/dependency_links.txt +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/pygitcode.egg-info/entry_points.txt +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/src/pygitcode.egg-info/requires.txt +0 -0
- {pygitcode-0.1.4 → pygitcode-0.1.6}/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.6
|
|
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)
|
|
@@ -126,6 +126,7 @@ reportUnknownMemberType = false
|
|
|
126
126
|
reportUnknownVariableType = false
|
|
127
127
|
reportUnknownArgumentType = false
|
|
128
128
|
reportUnknownParameterType = false
|
|
129
|
+
reportFunctionMemberAccess = false
|
|
129
130
|
reportOptionalSubscript = false
|
|
130
131
|
reportOptionalMemberAccess = false
|
|
131
132
|
reportOptionalIterable = false
|
|
@@ -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]
|
|
@@ -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
|
+
)
|
|
@@ -12,6 +12,7 @@ from .commands.auth import auth_group
|
|
|
12
12
|
from .commands.compat import compat_group
|
|
13
13
|
from .commands.issue import issue_group
|
|
14
14
|
from .commands.pr import pr_group
|
|
15
|
+
from .commands.setup import setup_group
|
|
15
16
|
from .config import get_token
|
|
16
17
|
from .context import AppContext
|
|
17
18
|
from .errors import GCError
|
|
@@ -68,7 +69,7 @@ set_gc_help(
|
|
|
68
69
|
gc_usage="gc <command> <subcommand> [flags]",
|
|
69
70
|
gc_command_sections=[
|
|
70
71
|
("CORE COMMANDS", ["auth", "issue", "pr"]),
|
|
71
|
-
("ADDITIONAL COMMANDS", ["compat", "completion", "version"]),
|
|
72
|
+
("ADDITIONAL COMMANDS", ["compat", "setup", "completion", "version"]),
|
|
72
73
|
],
|
|
73
74
|
gc_examples=[
|
|
74
75
|
"gc issue create",
|
|
@@ -86,6 +87,7 @@ auth_group.short_help = "Authenticate gc with GitCode" # type: ignore[attr-defi
|
|
|
86
87
|
issue_group.short_help = "Manage issues" # type: ignore[attr-defined]
|
|
87
88
|
pr_group.short_help = "Manage pull requests" # type: ignore[attr-defined]
|
|
88
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]
|
|
89
91
|
|
|
90
92
|
|
|
91
93
|
@main.command("version", short_help="Show gc version", help="Show gc version.") # type: ignore[attr-defined]
|
|
@@ -107,7 +109,8 @@ main.add_command(auth_group) # type: ignore[attr-defined]
|
|
|
107
109
|
main.add_command(issue_group) # type: ignore[attr-defined]
|
|
108
110
|
main.add_command(pr_group) # type: ignore[attr-defined]
|
|
109
111
|
main.add_command(compat_group) # type: ignore[attr-defined]
|
|
112
|
+
main.add_command(setup_group) # type: ignore[attr-defined]
|
|
110
113
|
|
|
111
114
|
|
|
112
115
|
if __name__ == "__main__": # pragma: no cover
|
|
113
|
-
main()
|
|
116
|
+
main() # type: ignore[call-issue]
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
3
6
|
import click
|
|
4
7
|
|
|
5
8
|
from ..config import get_token, load_config, save_config
|
|
@@ -20,7 +23,7 @@ def auth_group() -> None:
|
|
|
20
23
|
@click.option("--with-token", is_flag=True, help="Read token from stdin.")
|
|
21
24
|
def auth_login(with_token: bool) -> None:
|
|
22
25
|
if with_token:
|
|
23
|
-
token =
|
|
26
|
+
token = sys.stdin.read().strip()
|
|
24
27
|
if not token:
|
|
25
28
|
raise click.ClickException("No token provided on stdin.")
|
|
26
29
|
else:
|
|
@@ -53,10 +56,21 @@ def auth_status(json_fields: str | None, jq_query: str | None, template: str | N
|
|
|
53
56
|
try:
|
|
54
57
|
token = get_token()
|
|
55
58
|
except Exception:
|
|
56
|
-
safe_echo("
|
|
59
|
+
safe_echo("gitcode.com")
|
|
60
|
+
safe_echo(" X Not logged in to GitCode")
|
|
61
|
+
safe_echo(" - Run `gc auth login` or set GC_TOKEN to authenticate.")
|
|
62
|
+
if os.environ.get("GC_GH_PROXY_ACTIVE") == "1":
|
|
63
|
+
safe_echo(" - gh proxy: active; commands target GitCode, not GitHub")
|
|
64
|
+
raise click.exceptions.Exit(1) from None
|
|
57
65
|
return
|
|
58
66
|
masked = token[:4] + "****" if len(token) > 4 else "****"
|
|
59
|
-
safe_echo(
|
|
67
|
+
safe_echo("gitcode.com")
|
|
68
|
+
safe_echo(" ✓ Logged in to GitCode")
|
|
69
|
+
safe_echo(" - Active account: true")
|
|
70
|
+
safe_echo(" - API host: https://api.gitcode.com")
|
|
71
|
+
safe_echo(f" - Token: {masked}")
|
|
72
|
+
if os.environ.get("GC_GH_PROXY_ACTIVE") == "1":
|
|
73
|
+
safe_echo(" - gh proxy: active; commands target GitCode, not GitHub")
|
|
60
74
|
|
|
61
75
|
|
|
62
76
|
@auth_group.command("token", short_help="Print the active token", help="Print the active token.")
|