pygitcode 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

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.
gitcode_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.6"
@@ -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]
gitcode_cli/audit.py ADDED
@@ -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
+ )
gitcode_cli/cli.py CHANGED
@@ -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.")