pygitcode 0.1.4__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.
Files changed (44) hide show
  1. {pygitcode-0.1.4 → pygitcode-0.1.5}/PKG-INFO +12 -1
  2. {pygitcode-0.1.4 → pygitcode-0.1.5}/README.md +11 -0
  3. {pygitcode-0.1.4 → pygitcode-0.1.5}/pyproject.toml +1 -0
  4. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/__init__.py +1 -1
  5. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/adapters/issues.py +29 -12
  6. pygitcode-0.1.5/src/gitcode_cli/audit.py +442 -0
  7. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/cli.py +5 -2
  8. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/commands/auth.py +17 -3
  9. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/commands/pr.py +164 -8
  10. pygitcode-0.1.5/src/gitcode_cli/commands/setup.py +250 -0
  11. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/formatters.py +23 -0
  12. pygitcode-0.1.5/src/gitcode_cli/gh_proxy.py +216 -0
  13. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/helptext.py +11 -4
  14. pygitcode-0.1.5/src/gitcode_cli/repo.py +58 -0
  15. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/services/pulls.py +28 -18
  16. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/utils.py +1 -1
  17. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/pygitcode.egg-info/PKG-INFO +12 -1
  18. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/pygitcode.egg-info/SOURCES.txt +3 -0
  19. pygitcode-0.1.4/src/gitcode_cli/repo.py +0 -43
  20. {pygitcode-0.1.4 → pygitcode-0.1.5}/LICENSE +0 -0
  21. {pygitcode-0.1.4 → pygitcode-0.1.5}/setup.cfg +0 -0
  22. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/adapters/__init__.py +0 -0
  23. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/adapters/base.py +0 -0
  24. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/adapters/capabilities.py +0 -0
  25. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/adapters/pulls.py +0 -0
  26. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/cli_compat.py +0 -0
  27. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/client.py +0 -0
  28. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/commands/__init__.py +0 -0
  29. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/commands/compat.py +0 -0
  30. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/commands/issue.py +0 -0
  31. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/compat/__init__.py +0 -0
  32. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/compat/_types.py +0 -0
  33. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/compat/entries.py +0 -0
  34. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/compat/registry.py +0 -0
  35. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/config.py +0 -0
  36. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/context.py +0 -0
  37. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/errors.py +0 -0
  38. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/services/__init__.py +0 -0
  39. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/services/issues.py +0 -0
  40. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/gitcode_cli/services/users.py +0 -0
  41. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/pygitcode.egg-info/dependency_links.txt +0 -0
  42. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/pygitcode.egg-info/entry_points.txt +0 -0
  43. {pygitcode-0.1.4 → pygitcode-0.1.5}/src/pygitcode.egg-info/requires.txt +0 -0
  44. {pygitcode-0.1.4 → 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.4
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)
@@ -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
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
@@ -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
- 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:
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 = click.get_text_stream("stdin").read().strip()
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("Not logged in. Run `gc auth login` to authenticate.")
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(f"Logged in to GitCode (token: {masked})")
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.")