codeabc 0.1.0__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.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Flag over-long functions — functions whose body runs on for many lines.
|
|
2
|
+
|
|
3
|
+
Length is a readability signal that :mod:`complexity` and :mod:`deep_nesting` both
|
|
4
|
+
miss. A function can have very few branches (low cyclomatic complexity) and shallow
|
|
5
|
+
nesting, yet still be hundreds of lines of straight-line code — reading it means
|
|
6
|
+
scrolling and holding a long story in your head with no natural place to pause. For
|
|
7
|
+
a beginner these are the functions that feel exhausting even though no single line
|
|
8
|
+
is hard, and they are usually the clearest candidates for splitting into a few
|
|
9
|
+
named helper functions, each doing one thing.
|
|
10
|
+
|
|
11
|
+
This is deliberately a *physical line span* (``def`` line through the last line of
|
|
12
|
+
the body), which is the same yardstick common linters use, rather than a token or
|
|
13
|
+
statement count: it is the number a reader actually scrolls past, and it needs no
|
|
14
|
+
language-specific tuning to be meaningful to a newcomer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
|
|
21
|
+
# Functions at least this many lines long are surfaced.
|
|
22
|
+
_LENGTH_THRESHOLD = 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _function_length(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
26
|
+
"""Line span of a function, from its ``def`` line through the end of its body.
|
|
27
|
+
|
|
28
|
+
Decorators are excluded (``node.lineno`` points at ``def``, not at the first
|
|
29
|
+
decorator), so a function is not penalised for the decorators stacked above it.
|
|
30
|
+
Falls back to a single line when ``end_lineno`` is unavailable.
|
|
31
|
+
"""
|
|
32
|
+
end = getattr(node, "end_lineno", None)
|
|
33
|
+
if end is None:
|
|
34
|
+
return 1
|
|
35
|
+
return end - node.lineno + 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def scan_long_functions(
|
|
39
|
+
file_contents: dict[str, str], *, threshold: int = _LENGTH_THRESHOLD, limit: int = 15
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Find functions whose body spans ``threshold`` lines or more.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
45
|
+
threshold: minimum line span to flag a function.
|
|
46
|
+
limit: how many of the longest functions to return.
|
|
47
|
+
|
|
48
|
+
Returns ``{"total", "max_length", "threshold", "files"}`` where ``files`` lists
|
|
49
|
+
the longest functions (descending), each ``{"path", "function", "length"}``.
|
|
50
|
+
Test files and non-Python files are skipped. A nested helper is measured on its
|
|
51
|
+
own, so both an over-long outer function and an over-long inner one can surface.
|
|
52
|
+
"""
|
|
53
|
+
flagged: list[dict] = []
|
|
54
|
+
overall_max = 0
|
|
55
|
+
for path, content in file_contents.items():
|
|
56
|
+
if not content or not path.endswith(".py"):
|
|
57
|
+
continue
|
|
58
|
+
base = path.rsplit("/", 1)[-1]
|
|
59
|
+
if base.startswith("test_") or base.endswith("_test.py") or "/tests/" in path:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
tree = ast.parse(content)
|
|
63
|
+
except (SyntaxError, ValueError):
|
|
64
|
+
continue
|
|
65
|
+
for node in ast.walk(tree):
|
|
66
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
67
|
+
length = _function_length(node)
|
|
68
|
+
overall_max = max(overall_max, length)
|
|
69
|
+
if length >= threshold:
|
|
70
|
+
flagged.append({"path": path, "function": node.name, "length": length})
|
|
71
|
+
|
|
72
|
+
flagged.sort(key=lambda f: (-f["length"], f["path"], f["function"]))
|
|
73
|
+
return {
|
|
74
|
+
"total": len(flagged),
|
|
75
|
+
"max_length": overall_max,
|
|
76
|
+
"threshold": threshold,
|
|
77
|
+
"files": flagged[:limit],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_long_functions_markdown(project_name: str, data: dict | None) -> str:
|
|
82
|
+
"""Render the long-functions section, or an empty string when nothing is flagged."""
|
|
83
|
+
if not data or not data.get("files"):
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
threshold = data.get("threshold", _LENGTH_THRESHOLD)
|
|
87
|
+
lines = [
|
|
88
|
+
f"## 过长的函数({project_name})",
|
|
89
|
+
"",
|
|
90
|
+
"下面这些函数一口气写了很多行。哪怕里面没有复杂的分支、也没有很深的嵌套,"
|
|
91
|
+
"光是「长」本身就难读——你得一直往下滚,脑子里还要记着开头发生了什么,中间"
|
|
92
|
+
f"却没有一个自然的停顿点。这里列出**{threshold} 行或更长**的函数,它们通常"
|
|
93
|
+
"最值得拆成几个各司其职、名字取好的小函数:一个函数只做一件事,读的人就能"
|
|
94
|
+
"一段一段看懂,而不用一次消化一整篇。",
|
|
95
|
+
"",
|
|
96
|
+
"| 文件 | 函数 | 行数 |",
|
|
97
|
+
"| --- | --- | --- |",
|
|
98
|
+
]
|
|
99
|
+
for f in data["files"]:
|
|
100
|
+
lines.append(f"| `{f['path']}` | `{f['function']}` | {f['length']} |")
|
|
101
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Code ownership and knowledge-silo analysis from git history.
|
|
2
|
+
|
|
3
|
+
Churn says *how often* a file changes; ownership says *who* changes it. The
|
|
4
|
+
two are independent dynamic signals: a file can be a quiet corner that only one
|
|
5
|
+
person has ever touched (a knowledge silo — risky if they leave) without being
|
|
6
|
+
a churn hotspot at all.
|
|
7
|
+
|
|
8
|
+
For each file we derive, from the author of every commit that touched it:
|
|
9
|
+
|
|
10
|
+
* the primary author and how much of the history they own (a percentage),
|
|
11
|
+
* how many distinct authors have touched it,
|
|
12
|
+
* the *bus factor* — the smallest number of authors who together account for a
|
|
13
|
+
majority of the commits. A bus factor of 1 means a single person holds the
|
|
14
|
+
majority of the knowledge for that file.
|
|
15
|
+
|
|
16
|
+
:func:`analyze_ownership` is pure: it parses the same ``git log --numstat``
|
|
17
|
+
text that :func:`churn.collect_git_log` already produces, so it is unit-testable
|
|
18
|
+
with fixture strings and needs no real repository. We deliberately re-parse that
|
|
19
|
+
text rather than coupling to churn's internals, keeping the two analyses
|
|
20
|
+
independent.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
|
|
27
|
+
from .churn import _COMMIT_MARKER, _normalize_path
|
|
28
|
+
|
|
29
|
+
# A file needs at least this many commits before its ownership ratio is worth
|
|
30
|
+
# reporting — one or two commits by one person is not a meaningful "silo".
|
|
31
|
+
_MIN_COMMITS_FOR_SILO = 3
|
|
32
|
+
# A primary author owning at least this share of a file's commits makes it a
|
|
33
|
+
# knowledge-concentration risk worth surfacing.
|
|
34
|
+
_SILO_OWNERSHIP_PCT = 80
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bus_factor(author_commits: dict[str, int], total: int) -> int:
|
|
38
|
+
"""Smallest number of top authors whose commits exceed half the total."""
|
|
39
|
+
cumulative = 0
|
|
40
|
+
for rank, commits in enumerate(sorted(author_commits.values(), reverse=True), start=1):
|
|
41
|
+
cumulative += commits
|
|
42
|
+
if cumulative * 2 > total:
|
|
43
|
+
return rank
|
|
44
|
+
return len(author_commits)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ownership_reason(primary: str, pct: int, authors: int, bus_factor: int) -> str:
|
|
48
|
+
if bus_factor == 1 and authors == 1:
|
|
49
|
+
return f"只有 {primary} 一个人动过,知识完全集中,离职/请假就没人懂,最该补文档和结对。"
|
|
50
|
+
if bus_factor == 1:
|
|
51
|
+
return (
|
|
52
|
+
f"{primary} 掌握了 {pct}% 的改动,是事实上的 owner,有问题先找 ta,"
|
|
53
|
+
"但也别让知识只压一个人。"
|
|
54
|
+
)
|
|
55
|
+
return (
|
|
56
|
+
f"{authors} 个人共同维护({primary} 改得最多,占 {pct}%),知识相对分散,改动前可多方对齐。"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def analyze_ownership(
|
|
61
|
+
git_log_text: str | None,
|
|
62
|
+
*,
|
|
63
|
+
scanned_paths: set[str] | list[str] | None = None,
|
|
64
|
+
limit: int = 8,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""Parse ``git log --numstat`` text into per-file ownership and silos.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
git_log_text: output of :func:`churn.collect_git_log` (``None`` ⇒ empty).
|
|
70
|
+
scanned_paths: if given, only these paths are considered, so ownership
|
|
71
|
+
lines up with the files CodeABC actually scanned.
|
|
72
|
+
limit: how many entries to return in each list.
|
|
73
|
+
|
|
74
|
+
Returns ``{"owners", "silos", "commits_analyzed"}``. ``owners`` ranks the
|
|
75
|
+
busiest files by commit count with their primary author; ``silos`` is the
|
|
76
|
+
subset whose knowledge is concentrated in one person (bus factor 1 and a
|
|
77
|
+
dominant owner), sorted so the riskiest (most commits behind one person)
|
|
78
|
+
come first.
|
|
79
|
+
"""
|
|
80
|
+
allowed: set[str] | None = set(scanned_paths) if scanned_paths is not None else None
|
|
81
|
+
|
|
82
|
+
# path -> author -> number of commits that author made touching the path
|
|
83
|
+
author_commits: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
84
|
+
commits_analyzed = 0
|
|
85
|
+
|
|
86
|
+
current_author = ""
|
|
87
|
+
touched: set[str] = set()
|
|
88
|
+
|
|
89
|
+
def _flush(files: set[str], author: str) -> None:
|
|
90
|
+
if not files:
|
|
91
|
+
return
|
|
92
|
+
nonlocal commits_analyzed
|
|
93
|
+
commits_analyzed += 1
|
|
94
|
+
for path in files:
|
|
95
|
+
author_commits[path][author] += 1
|
|
96
|
+
|
|
97
|
+
for line in (git_log_text or "").splitlines():
|
|
98
|
+
if line.startswith(_COMMIT_MARKER):
|
|
99
|
+
_flush(touched, current_author)
|
|
100
|
+
touched = set()
|
|
101
|
+
parts = line[len(_COMMIT_MARKER) :].split("::")
|
|
102
|
+
current_author = parts[2] if len(parts) >= 3 else ""
|
|
103
|
+
continue
|
|
104
|
+
if not line.strip():
|
|
105
|
+
continue
|
|
106
|
+
cols = line.split("\t")
|
|
107
|
+
if len(cols) != 3:
|
|
108
|
+
continue
|
|
109
|
+
_added, _deleted, raw_path = cols
|
|
110
|
+
path = _normalize_path(raw_path)
|
|
111
|
+
if not path or (allowed is not None and path not in allowed):
|
|
112
|
+
continue
|
|
113
|
+
touched.add(path)
|
|
114
|
+
_flush(touched, current_author)
|
|
115
|
+
|
|
116
|
+
owners = []
|
|
117
|
+
for path, commits_by_author in author_commits.items():
|
|
118
|
+
total = sum(commits_by_author.values())
|
|
119
|
+
if total < 2:
|
|
120
|
+
continue
|
|
121
|
+
primary, primary_commits = max(commits_by_author.items(), key=lambda kv: (kv[1], kv[0]))
|
|
122
|
+
pct = round(primary_commits / total * 100)
|
|
123
|
+
authors = len(commits_by_author)
|
|
124
|
+
bus_factor = _bus_factor(commits_by_author, total)
|
|
125
|
+
owners.append(
|
|
126
|
+
{
|
|
127
|
+
"path": path,
|
|
128
|
+
"primary_author": primary,
|
|
129
|
+
"ownership": pct,
|
|
130
|
+
"authors": authors,
|
|
131
|
+
"commits": total,
|
|
132
|
+
"bus_factor": bus_factor,
|
|
133
|
+
"reason": _ownership_reason(primary, pct, authors, bus_factor),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
owners.sort(key=lambda o: (-o["commits"], o["bus_factor"], o["path"]))
|
|
138
|
+
|
|
139
|
+
silos = [
|
|
140
|
+
o
|
|
141
|
+
for o in owners
|
|
142
|
+
if o["bus_factor"] == 1
|
|
143
|
+
and o["ownership"] >= _SILO_OWNERSHIP_PCT
|
|
144
|
+
and o["commits"] >= _MIN_COMMITS_FOR_SILO
|
|
145
|
+
]
|
|
146
|
+
silos.sort(key=lambda o: (-o["commits"], -o["ownership"], o["path"]))
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"owners": owners[:limit],
|
|
150
|
+
"silos": silos[:limit],
|
|
151
|
+
"commits_analyzed": commits_analyzed,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_ownership_markdown(project_name: str, ownership_data: dict | None) -> str:
|
|
156
|
+
"""Render the ownership analysis as a Markdown section, or ``""`` if empty.
|
|
157
|
+
|
|
158
|
+
Returns an empty string when there's no usable history (e.g. an uploaded,
|
|
159
|
+
non-git project) so callers can skip it cleanly.
|
|
160
|
+
"""
|
|
161
|
+
data = ownership_data or {}
|
|
162
|
+
owners = data.get("owners") or []
|
|
163
|
+
silos = data.get("silos") or []
|
|
164
|
+
if not owners:
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
analyzed = data.get("commits_analyzed", 0)
|
|
168
|
+
lines = [
|
|
169
|
+
f"# {project_name} — 代码归属",
|
|
170
|
+
"",
|
|
171
|
+
f"> 基于最近 {analyzed} 个提交的作者算出,看这块代码该问谁、知识有没有压在一个人身上。",
|
|
172
|
+
"",
|
|
173
|
+
"## 谁在维护(按改动次数)",
|
|
174
|
+
"",
|
|
175
|
+
]
|
|
176
|
+
lines.extend(
|
|
177
|
+
f"- `{o['path']}` — 主要是 {o['primary_author']}"
|
|
178
|
+
f"(占 {o['ownership']}%、共 {o['authors']} 人、bus factor {o['bus_factor']}):"
|
|
179
|
+
f"{o['reason']}"
|
|
180
|
+
for o in owners
|
|
181
|
+
)
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
if silos:
|
|
185
|
+
lines.append("## 知识孤岛(只压在一个人身上)")
|
|
186
|
+
lines.append("")
|
|
187
|
+
lines.extend(
|
|
188
|
+
f"- `{s['path']}` — {s['primary_author']} 一个人掌握了 {s['ownership']}%"
|
|
189
|
+
f"({s['commits']} 次改动):建议补文档、结对或 code review 分散知识。"
|
|
190
|
+
for s in silos
|
|
191
|
+
)
|
|
192
|
+
lines.append("")
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Package purposes: explain what a third-party library is for, in plain words.
|
|
2
|
+
|
|
3
|
+
:mod:`backend.services.dependencies` lists the packages a project installs, but a
|
|
4
|
+
bare name like ``fastapi``, ``pydantic`` or ``sqlalchemy`` means nothing to a
|
|
5
|
+
non-programmer. This is a curated dictionary (same plain-language voice as
|
|
6
|
+
:mod:`backend.services.glossary` and :mod:`backend.services.filenames`) mapping
|
|
7
|
+
the most common Python and JavaScript packages to a one-line "what it does, and
|
|
8
|
+
why a project would pull it in".
|
|
9
|
+
|
|
10
|
+
:func:`explain_package` normalises the name (lowercases, strips extras such as
|
|
11
|
+
``uvicorn[standard]``, and resolves a few well-known aliases) before looking it
|
|
12
|
+
up, and returns ``None`` for anything not in the dictionary. Deterministic — no
|
|
13
|
+
network, no LLM — so it is instant and unit-testable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
# Canonical lowercase package name -> plain-language purpose.
|
|
21
|
+
_PURPOSES: dict[str, str] = {
|
|
22
|
+
# --- Python: web / API ---
|
|
23
|
+
"fastapi": "搭 Web API 的框架,用很少的代码就能对外提供接口,自带数据校验和文档。",
|
|
24
|
+
"flask": "一个轻量的 Web 框架,用来快速搭网站或接口,小项目常用。",
|
|
25
|
+
"django": "一个全功能 Web 框架,自带数据库、后台管理、用户系统,适合做完整网站。",
|
|
26
|
+
"starlette": "FastAPI 底层用的异步 Web 工具库,负责处理请求和路由。",
|
|
27
|
+
"uvicorn": "把 Python Web 应用真正跑起来的服务器,FastAPI 项目几乎都用它启动。",
|
|
28
|
+
"gunicorn": "生产环境里跑 Python Web 应用的服务器,能开多个进程扛并发。",
|
|
29
|
+
"requests": "发 HTTP 请求的库(访问网页、调别人的接口),是 Python 里最常见的之一。",
|
|
30
|
+
"httpx": "和 requests 类似的发 HTTP 请求的库,但支持异步、更现代。",
|
|
31
|
+
"aiohttp": "异步地发 HTTP 请求或搭服务器,适合要同时处理很多网络请求的场景。",
|
|
32
|
+
"websockets": "处理 WebSocket 长连接,用来做实时双向通信(如聊天、推送)。",
|
|
33
|
+
# --- Python: data / validation ---
|
|
34
|
+
"pydantic": "定义数据该长什么样并自动校验,传进来的数据不合规矩会直接报错。",
|
|
35
|
+
"numpy": "数值计算的基础库,处理大批数字和矩阵又快又省,科学计算的地基。",
|
|
36
|
+
"pandas": "处理表格数据(像代码版的 Excel),做数据清洗和分析的主力工具。",
|
|
37
|
+
"scipy": "在 numpy 之上的科学计算库,提供统计、优化、信号处理等高级算法。",
|
|
38
|
+
"matplotlib": "把数据画成图表(折线、柱状、散点等)的画图库。",
|
|
39
|
+
"polars": "和 pandas 类似的表格数据处理库,主打速度快、省内存。",
|
|
40
|
+
# --- Python: AI / ML ---
|
|
41
|
+
"torch": "PyTorch,做深度学习/训练神经网络的主流框架。",
|
|
42
|
+
"tensorflow": "Google 的深度学习框架,和 PyTorch 并列的两大主流之一。",
|
|
43
|
+
"transformers": "Hugging Face 的库,几行代码就能加载和使用各种预训练大模型。",
|
|
44
|
+
"openai": "调用 OpenAI(GPT 等)模型的官方库。",
|
|
45
|
+
"anthropic": "调用 Anthropic(Claude)模型的官方库。",
|
|
46
|
+
"langchain": "把大模型和工具、数据、流程串起来做 AI 应用的框架。",
|
|
47
|
+
"scikit-learn": "经典机器学习库(分类、回归、聚类等),不涉及深度学习时常用。",
|
|
48
|
+
"datasets": "Hugging Face 的库,方便地下载和处理机器学习数据集。",
|
|
49
|
+
# --- Python: database / cache ---
|
|
50
|
+
"sqlalchemy": "用 Python 对象操作数据库,不用手写一堆 SQL。",
|
|
51
|
+
"psycopg2": "连接 PostgreSQL 数据库的驱动。",
|
|
52
|
+
"pymysql": "连接 MySQL 数据库的驱动。",
|
|
53
|
+
"redis": "连接 Redis(内存数据库/缓存)的库,常用来做缓存和队列。",
|
|
54
|
+
"pymongo": "连接 MongoDB(文档数据库)的库。",
|
|
55
|
+
"alembic": "管理数据库结构变更的工具,给 SQLAlchemy 配套做迁移。",
|
|
56
|
+
# --- Python: utilities ---
|
|
57
|
+
"pyyaml": "读写 YAML 格式的配置文件。",
|
|
58
|
+
"python-dotenv": "把 .env 文件里的配置加载成环境变量。",
|
|
59
|
+
"pillow": "处理图片(打开、缩放、裁剪、转格式)的库。",
|
|
60
|
+
"opencv-python": "做图像和视频处理、计算机视觉的库(OpenCV)。",
|
|
61
|
+
"beautifulsoup4": "从 HTML 网页里抽取数据(爬虫常用)。",
|
|
62
|
+
"click": "做命令行工具的库,帮你定义命令、参数和帮助信息。",
|
|
63
|
+
"rich": "在终端里打印彩色、表格、进度条等漂亮输出。",
|
|
64
|
+
"tqdm": "给循环加一个进度条,一眼看出跑到哪了。",
|
|
65
|
+
"celery": "做后台任务队列,把耗时活儿丢到后台异步处理。",
|
|
66
|
+
"boto3": "操作 AWS 云服务(S3 存储、各种云资源)的官方库。",
|
|
67
|
+
# --- Python: dev / test ---
|
|
68
|
+
"pytest": "Python 最常用的测试框架,用来自动检查代码对不对。",
|
|
69
|
+
"ruff": "又快又全的代码检查+格式化工具,挑出风格和潜在问题。",
|
|
70
|
+
"mypy": "静态类型检查器,在不运行代码的情况下找出类型用错的地方。",
|
|
71
|
+
"black": "自动把代码格式化成统一风格,省得手动调缩进和换行。",
|
|
72
|
+
"flake8": "检查代码风格和明显错误的工具。",
|
|
73
|
+
# --- JavaScript / TypeScript ---
|
|
74
|
+
"react": "搭前端界面的主流框架,把页面拆成可复用的组件。",
|
|
75
|
+
"react-dom": "把 React 组件真正渲染到网页上的配套库。",
|
|
76
|
+
"vue": "和 React 并列的前端框架,搭交互式网页界面。",
|
|
77
|
+
"express": "Node.js 上最常用的后端 Web 框架,搭接口和服务器。",
|
|
78
|
+
"axios": "在浏览器或 Node 里发 HTTP 请求的库。",
|
|
79
|
+
"lodash": "一堆现成的 JavaScript 工具函数(处理数组、对象等),省得自己写。",
|
|
80
|
+
"typescript": "给 JavaScript 加类型的语言,写大型项目更稳、更好维护。",
|
|
81
|
+
"vite": "现代前端构建/开发工具,启动快、热更新快。",
|
|
82
|
+
"webpack": "把前端代码和资源打包成浏览器能跑的文件的构建工具。",
|
|
83
|
+
"tailwindcss": "用一串小类名直接写样式的 CSS 框架,不用单独写 CSS 文件。",
|
|
84
|
+
"next": "基于 React 的全栈框架,自带路由、服务端渲染等。",
|
|
85
|
+
"eslint": "检查 JavaScript/TypeScript 代码风格和问题的工具。",
|
|
86
|
+
"zod": "在 TypeScript 里定义并校验数据格式的库(类似 Python 的 pydantic)。",
|
|
87
|
+
"zustand": "React 里管理共享状态的轻量库。",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Aliases: the name as it may appear in a manifest -> the canonical key above.
|
|
91
|
+
_ALIASES: dict[str, str] = {
|
|
92
|
+
"cv2": "opencv-python",
|
|
93
|
+
"sklearn": "scikit-learn",
|
|
94
|
+
"pil": "pillow",
|
|
95
|
+
"bs4": "beautifulsoup4",
|
|
96
|
+
"yaml": "pyyaml",
|
|
97
|
+
"dotenv": "python-dotenv",
|
|
98
|
+
"pytorch": "torch",
|
|
99
|
+
"react-router-dom": "react",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Strip an extras suffix like "uvicorn[standard]" -> "uvicorn".
|
|
103
|
+
_EXTRAS_RE = re.compile(r"\[.*\]$")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def explain_package(name: str) -> str | None:
|
|
107
|
+
"""Return a plain-language purpose for *name*, or ``None`` if unknown."""
|
|
108
|
+
if not name:
|
|
109
|
+
return None
|
|
110
|
+
key = _EXTRAS_RE.sub("", name.strip().lower())
|
|
111
|
+
key = _ALIASES.get(key, key)
|
|
112
|
+
return _PURPOSES.get(key)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Pull-request reading mode: turn a PR link into a plain-language diff walkthrough.
|
|
2
|
+
|
|
3
|
+
Deterministic core, no LLM call: fetch the unified diff, break it into
|
|
4
|
+
per-file changes, classify each file's change type, and propose a reading
|
|
5
|
+
order. An LLM explanation layer can sit on top of this payload later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
_PR_URL_RE = re.compile(r"^https?://(?:www\.)?github\.com/([^/]+)/([^/]+)/pull/(\d+)(?:/[^\s]*)?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PrRef:
|
|
21
|
+
owner: str
|
|
22
|
+
repo: str
|
|
23
|
+
number: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileChange:
|
|
28
|
+
path: str
|
|
29
|
+
added: int
|
|
30
|
+
deleted: int
|
|
31
|
+
change_type: str # code | test | docs | config | other
|
|
32
|
+
hunks: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_pr_url(url: str) -> PrRef:
|
|
36
|
+
"""Parse https://github.com/owner/repo/pull/123 (any suffix after the number)."""
|
|
37
|
+
match = _PR_URL_RE.match(url.strip())
|
|
38
|
+
if not match:
|
|
39
|
+
raise ValueError(f"not a GitHub pull request URL: {url}")
|
|
40
|
+
owner, repo, number = match.group(1), match.group(2), int(match.group(3))
|
|
41
|
+
return PrRef(owner=owner, repo=repo.removesuffix(".git"), number=number)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fetch_pr_diff(ref: PrRef, timeout: float = 30.0) -> str:
|
|
45
|
+
"""Fetch the PR's unified diff. Try gh first (auth'd), fall back to patch-diff URL."""
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["gh", "pr", "diff", str(ref.number), "--repo", f"{ref.owner}/{ref.repo}"],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
54
|
+
return result.stdout
|
|
55
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
56
|
+
pass
|
|
57
|
+
url = f"https://patch-diff.githubusercontent.com/raw/{ref.owner}/{ref.repo}/pull/{ref.number}.diff"
|
|
58
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 (fixed host)
|
|
59
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_FILE_KIND_RULES: tuple[tuple[str, str], ...] = (
|
|
63
|
+
("test", "test"),
|
|
64
|
+
("docs", "docs"),
|
|
65
|
+
("config", "config"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _file_kind(path: str) -> str:
|
|
70
|
+
p = path.lower()
|
|
71
|
+
name = p.rsplit("/", 1)[-1]
|
|
72
|
+
if any(
|
|
73
|
+
tok in p for tok in ("/tests/", "/test/", "test_", "_test.", ".test.", ".spec.")
|
|
74
|
+
) or name.startswith(("test_", "tests_")):
|
|
75
|
+
return "test"
|
|
76
|
+
if name.endswith((".md", ".rst", ".txt")) or p.startswith(("docs/", "doc/")):
|
|
77
|
+
return "docs"
|
|
78
|
+
if name.endswith((".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".lock")) or name in {
|
|
79
|
+
"dockerfile",
|
|
80
|
+
"makefile",
|
|
81
|
+
"justfile",
|
|
82
|
+
}:
|
|
83
|
+
return "config"
|
|
84
|
+
return "code"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_diff_files(diff_text: str) -> list[FileChange]:
|
|
88
|
+
"""Break a unified diff into per-file changes with add/del counts."""
|
|
89
|
+
files: list[FileChange] = []
|
|
90
|
+
current: FileChange | None = None
|
|
91
|
+
for line in diff_text.splitlines():
|
|
92
|
+
if line.startswith("diff --git "):
|
|
93
|
+
if current is not None:
|
|
94
|
+
files.append(current)
|
|
95
|
+
match = re.match(r"diff --git a/(\S+) b/(\S+)", line)
|
|
96
|
+
path = match.group(2) if match else "unknown"
|
|
97
|
+
current = FileChange(path=path, added=0, deleted=0, change_type=_file_kind(path))
|
|
98
|
+
elif current is not None:
|
|
99
|
+
if line.startswith("@@"):
|
|
100
|
+
current.hunks.append(line)
|
|
101
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
102
|
+
current.added += 1
|
|
103
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
104
|
+
current.deleted += 1
|
|
105
|
+
if current is not None:
|
|
106
|
+
files.append(current)
|
|
107
|
+
return files
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reading_order(files: list[FileChange]) -> list[FileChange]:
|
|
111
|
+
"""Suggest a review order: biggest production-code diffs first, then tests, then docs/config."""
|
|
112
|
+
rank = {"code": 0, "test": 1, "config": 2, "docs": 3, "other": 4}
|
|
113
|
+
return sorted(
|
|
114
|
+
files,
|
|
115
|
+
key=lambda f: (rank.get(f.change_type, 4), -(f.added + f.deleted), f.path),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def summarize_diff(files: list[FileChange]) -> dict:
|
|
120
|
+
"""Plain-language rollup of what the PR touches, no LLM needed."""
|
|
121
|
+
by_kind: dict[str, int] = {}
|
|
122
|
+
for f in files:
|
|
123
|
+
by_kind[f.change_type] = by_kind.get(f.change_type, 0) + 1
|
|
124
|
+
total_added = sum(f.added for f in files)
|
|
125
|
+
total_deleted = sum(f.deleted for f in files)
|
|
126
|
+
biggest = max(files, key=lambda f: f.added + f.deleted) if files else None
|
|
127
|
+
return {
|
|
128
|
+
"file_count": len(files),
|
|
129
|
+
"by_kind": by_kind,
|
|
130
|
+
"total_added": total_added,
|
|
131
|
+
"total_deleted": total_deleted,
|
|
132
|
+
"biggest_file": biggest.path if biggest else None,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def analyze_pr(url: str) -> dict:
|
|
137
|
+
"""Full pipeline: URL -> diff -> per-file breakdown with a suggested reading order."""
|
|
138
|
+
ref = parse_pr_url(url)
|
|
139
|
+
diff_text = fetch_pr_diff(ref)
|
|
140
|
+
files = parse_diff_files(diff_text)
|
|
141
|
+
ordered = reading_order(files)
|
|
142
|
+
return {
|
|
143
|
+
"url": url,
|
|
144
|
+
"owner": ref.owner,
|
|
145
|
+
"repo": ref.repo,
|
|
146
|
+
"number": ref.number,
|
|
147
|
+
"summary": summarize_diff(files),
|
|
148
|
+
"files": [
|
|
149
|
+
{
|
|
150
|
+
"path": f.path,
|
|
151
|
+
"added": f.added,
|
|
152
|
+
"deleted": f.deleted,
|
|
153
|
+
"change_type": f.change_type,
|
|
154
|
+
"hunks": f.hunks,
|
|
155
|
+
}
|
|
156
|
+
for f in ordered
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_markdown(analysis: dict) -> str:
|
|
162
|
+
"""Human-readable walkthrough of the analysis payload."""
|
|
163
|
+
summary = analysis["summary"]
|
|
164
|
+
lines = [
|
|
165
|
+
f"# PR reading: {analysis['owner']}/{analysis['repo']}#{analysis['number']}",
|
|
166
|
+
"",
|
|
167
|
+
(
|
|
168
|
+
f"{summary['file_count']} files touched "
|
|
169
|
+
f"(+{summary['total_added']} / -{summary['total_deleted']}). "
|
|
170
|
+
+ ", ".join(f"{kind}: {count}" for kind, count in sorted(summary["by_kind"].items()))
|
|
171
|
+
),
|
|
172
|
+
"",
|
|
173
|
+
"## Suggested reading order",
|
|
174
|
+
]
|
|
175
|
+
for i, f in enumerate(analysis["files"], start=1):
|
|
176
|
+
lines.append(f"{i}. `{f['path']}` ({f['change_type']}, +{f['added']} / -{f['deleted']})")
|
|
177
|
+
return "\n".join(lines) + "\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def to_json(analysis: dict) -> str:
|
|
181
|
+
return json.dumps(analysis, ensure_ascii=False, indent=2)
|