repokg 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.
- repokg/__init__.py +3 -0
- repokg/__main__.py +6 -0
- repokg/cli.py +144 -0
- repokg/code.py +104 -0
- repokg/deps.py +166 -0
- repokg/github.py +66 -0
- repokg/gitinfo.py +124 -0
- repokg/inject.py +84 -0
- repokg/markdown.py +319 -0
- repokg/ops.py +80 -0
- repokg/prompts.py +61 -0
- repokg-0.1.0.dist-info/METADATA +160 -0
- repokg-0.1.0.dist-info/RECORD +17 -0
- repokg-0.1.0.dist-info/WHEEL +5 -0
- repokg-0.1.0.dist-info/entry_points.txt +2 -0
- repokg-0.1.0.dist-info/licenses/LICENSE +21 -0
- repokg-0.1.0.dist-info/top_level.txt +1 -0
repokg/__init__.py
ADDED
repokg/__main__.py
ADDED
repokg/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""repokg CLI: scan | prompts | render | generate."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__, code, deps, github, gitinfo, inject, markdown, ops, prompts
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def scan(repo, out, no_github, pr_limit):
|
|
13
|
+
info, branches = gitinfo.collect(repo)
|
|
14
|
+
if no_github:
|
|
15
|
+
prs, note = [], "GitHub lookup disabled (--no-github)"
|
|
16
|
+
else:
|
|
17
|
+
prs, note = github.collect(repo, pr_limit)
|
|
18
|
+
github.classify(branches, prs, info["trunk"], info["integration"])
|
|
19
|
+
tree = dict(code.walk(repo)) # single filesystem walk, shared by all collectors
|
|
20
|
+
languages, modules = code.collect(repo, tree)
|
|
21
|
+
kg = {
|
|
22
|
+
"repokg_version": 1,
|
|
23
|
+
"generated_at": datetime.date.today().isoformat(),
|
|
24
|
+
"repo": info,
|
|
25
|
+
"languages": languages,
|
|
26
|
+
"modules": modules,
|
|
27
|
+
"edges": deps.collect(repo, tree),
|
|
28
|
+
"branches": branches,
|
|
29
|
+
"prs": prs,
|
|
30
|
+
"github_note": note,
|
|
31
|
+
"ops": ops.collect(repo, tree),
|
|
32
|
+
}
|
|
33
|
+
os.makedirs(out, exist_ok=True)
|
|
34
|
+
path = os.path.join(out, "kg.json")
|
|
35
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
36
|
+
json.dump(kg, f, indent=1)
|
|
37
|
+
print("wrote %s (%d modules, %d edges, %d branches, %d PRs)" %
|
|
38
|
+
(path, len(modules), len(kg["edges"]), len(branches), len(prs)))
|
|
39
|
+
return kg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_prompts(repo, out, md):
|
|
43
|
+
pdir = os.path.join(out, "prompts")
|
|
44
|
+
os.makedirs(pdir, exist_ok=True)
|
|
45
|
+
path = os.path.join(pdir, "enrich.md")
|
|
46
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
47
|
+
f.write(prompts.render(repo, out, md))
|
|
48
|
+
print("wrote %s (hand this to your AI agent)" % path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render(out, md):
|
|
52
|
+
with open(os.path.join(out, "kg.json"), encoding="utf-8") as f:
|
|
53
|
+
kg = json.load(f)
|
|
54
|
+
narratives = {}
|
|
55
|
+
npath = os.path.join(out, "narratives.json")
|
|
56
|
+
if os.path.isfile(npath):
|
|
57
|
+
with open(npath, encoding="utf-8") as f:
|
|
58
|
+
narratives = json.load(f)
|
|
59
|
+
doc = markdown.render(kg, narratives)
|
|
60
|
+
with open(md, "w", encoding="utf-8") as f:
|
|
61
|
+
f.write(doc)
|
|
62
|
+
state = "enriched" if narratives else "structure-only; run .repokg/prompts/enrich.md to enrich"
|
|
63
|
+
print("wrote %s (%s)" % (md, state))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def do_inject(repo, md):
|
|
67
|
+
for path, status in inject.run(repo, md).items():
|
|
68
|
+
print("%s: %s" % (path, status))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check(repo, out, md):
|
|
72
|
+
"""Exit 0 if the knowledge graph matches HEAD, 1 if stale/missing. CI-friendly."""
|
|
73
|
+
apath = os.path.join(out, "kg.json")
|
|
74
|
+
if not os.path.isfile(apath) or not os.path.isfile(md):
|
|
75
|
+
print("stale: knowledge graph not generated (run `repokg generate`)")
|
|
76
|
+
return 1
|
|
77
|
+
with open(apath, encoding="utf-8") as f:
|
|
78
|
+
stored = json.load(f).get("repo", {}).get("head", "")
|
|
79
|
+
head = gitinfo.try_run(repo, "rev-parse", "HEAD")
|
|
80
|
+
if stored and head and stored != head:
|
|
81
|
+
print("stale: knowledge graph at %s, HEAD is %s (run `repokg generate`)"
|
|
82
|
+
% (stored[:12], head[:12]))
|
|
83
|
+
return 1
|
|
84
|
+
print("fresh: knowledge graph matches HEAD %s" % (head[:12] or "(unknown)"))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv=None):
|
|
89
|
+
ap = argparse.ArgumentParser(
|
|
90
|
+
prog="repokg",
|
|
91
|
+
description="Generate an AI-ready knowledge graph of a codebase.")
|
|
92
|
+
ap.add_argument("command", nargs="?", default="generate",
|
|
93
|
+
choices=["scan", "prompts", "render", "generate", "inject",
|
|
94
|
+
"check", "version"],
|
|
95
|
+
help="scan: extract structure to .repokg/kg.json | "
|
|
96
|
+
"prompts: write the AI enrichment prompt | "
|
|
97
|
+
"render: kg.json (+narratives.json) -> KNOWLEDGE_GRAPH.md | "
|
|
98
|
+
"generate: scan + prompts + render (default) | "
|
|
99
|
+
"inject: add knowledge-graph pointer to CLAUDE.md/AGENTS.md/cursor rules | "
|
|
100
|
+
"check: exit 1 if knowledge graph is stale vs HEAD")
|
|
101
|
+
ap.add_argument("path", nargs="?", default=".", help="repository path (default: .)")
|
|
102
|
+
ap.add_argument("--out", default=None, help="output dir (default: <repo>/.repokg)")
|
|
103
|
+
ap.add_argument("--md", default=None, help="markdown output (default: <repo>/KNOWLEDGE_GRAPH.md)")
|
|
104
|
+
ap.add_argument("--no-github", action="store_true", help="skip gh PR lookup")
|
|
105
|
+
ap.add_argument("--pr-limit", type=int, default=1000, help="max PRs to fetch (default 1000)")
|
|
106
|
+
args = ap.parse_args(argv)
|
|
107
|
+
|
|
108
|
+
if args.command == "version":
|
|
109
|
+
print("repokg %s" % __version__)
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
repo = os.path.abspath(args.path)
|
|
113
|
+
if not os.path.isdir(repo):
|
|
114
|
+
print("error: %s is not a directory" % repo, file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
out = args.out or os.path.join(repo, ".repokg")
|
|
117
|
+
md = args.md or os.path.join(repo, "KNOWLEDGE_GRAPH.md")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
if args.command == "scan":
|
|
121
|
+
scan(repo, out, args.no_github, args.pr_limit)
|
|
122
|
+
elif args.command == "prompts":
|
|
123
|
+
write_prompts(repo, out, md)
|
|
124
|
+
elif args.command == "render":
|
|
125
|
+
render(out, md)
|
|
126
|
+
elif args.command == "inject":
|
|
127
|
+
do_inject(repo, md)
|
|
128
|
+
elif args.command == "check":
|
|
129
|
+
return check(repo, out, md)
|
|
130
|
+
else: # generate
|
|
131
|
+
scan(repo, out, args.no_github, args.pr_limit)
|
|
132
|
+
write_prompts(repo, out, md)
|
|
133
|
+
render(out, md)
|
|
134
|
+
except RuntimeError as e:
|
|
135
|
+
print("error: %s" % e, file=sys.stderr)
|
|
136
|
+
return 1
|
|
137
|
+
except FileNotFoundError as e:
|
|
138
|
+
print("error: %s (run `repokg scan` first?)" % e, file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
repokg/code.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Code collectors: language breakdown, module discovery, LOC."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
SKIP_DIRS = {
|
|
7
|
+
".git", "node_modules", "vendor", "dist", "build", "out", "target", ".next",
|
|
8
|
+
".venv", "venv", "__pycache__", ".idea", ".vscode", ".repokg", "coverage",
|
|
9
|
+
".terraform", "third_party", ".tox", ".mypy_cache", ".ruff_cache",
|
|
10
|
+
"site-packages", ".pytest_cache", ".cache",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
LANG_BY_EXT = {
|
|
14
|
+
".go": "Go", ".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript",
|
|
15
|
+
".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript",
|
|
16
|
+
".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".rb": "Ruby",
|
|
17
|
+
".php": "PHP", ".cs": "C#", ".c": "C", ".cpp": "C++", ".cc": "C++",
|
|
18
|
+
".h": "C/C++", ".hpp": "C++", ".sol": "Solidity", ".swift": "Swift",
|
|
19
|
+
".scala": "Scala", ".ex": "Elixir", ".exs": "Elixir", ".zig": "Zig",
|
|
20
|
+
".lua": "Lua", ".dart": "Dart", ".vue": "Vue", ".svelte": "Svelte",
|
|
21
|
+
".sql": "SQL", ".sh": "Shell", ".proto": "Protobuf", ".tf": "Terraform",
|
|
22
|
+
".yaml": "YAML", ".yml": "YAML", ".html": "HTML", ".css": "CSS",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Languages whose presence makes a directory a "module" worth graphing.
|
|
26
|
+
MODULE_LANGS = {
|
|
27
|
+
"Go", "Python", "TypeScript", "JavaScript", "Rust", "Java", "Kotlin",
|
|
28
|
+
"Ruby", "PHP", "C#", "C", "C++", "Solidity", "Swift", "Scala", "Elixir",
|
|
29
|
+
"Zig", "Lua", "Dart", "Vue", "Svelte",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ROOT_MARKERS = {
|
|
33
|
+
"go.mod", "package.json", "pyproject.toml", "setup.py", "Cargo.toml",
|
|
34
|
+
"pom.xml", "build.gradle", "composer.json", "Gemfile",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
GENERATED_RE = re.compile(r"(generated|sqlcgen|_pb2|\.pb\.|/pb$|/pb/|bindings)")
|
|
38
|
+
MAX_FILE_BYTES = 2_000_000
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def walk(repo):
|
|
42
|
+
"""os.walk with skip-dir pruning; yields (rel_dir, filenames)."""
|
|
43
|
+
for dirpath, dirnames, filenames in os.walk(repo):
|
|
44
|
+
dirnames[:] = sorted(
|
|
45
|
+
d for d in dirnames
|
|
46
|
+
if d not in SKIP_DIRS and not (d.startswith(".") and d != ".github")
|
|
47
|
+
)
|
|
48
|
+
rel = os.path.relpath(dirpath, repo)
|
|
49
|
+
yield ("" if rel == "." else rel.replace(os.sep, "/")), filenames
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def count_loc(path):
|
|
53
|
+
try:
|
|
54
|
+
if os.path.getsize(path) > MAX_FILE_BYTES:
|
|
55
|
+
return 0
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
data = f.read()
|
|
58
|
+
if b"\0" in data[:1024]: # binary
|
|
59
|
+
return 0
|
|
60
|
+
return data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
|
|
61
|
+
except OSError:
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect(repo, tree=None):
|
|
66
|
+
"""Return (languages, modules).
|
|
67
|
+
|
|
68
|
+
languages: [{lang, files, loc}] sorted by loc desc
|
|
69
|
+
modules: [{path, lang, files, loc, root, generated}] one per dir containing code
|
|
70
|
+
tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo.
|
|
71
|
+
"""
|
|
72
|
+
lang_stats = {}
|
|
73
|
+
modules = []
|
|
74
|
+
for rel, files in (tree.items() if tree is not None else walk(repo)):
|
|
75
|
+
if rel.startswith(".github"):
|
|
76
|
+
continue
|
|
77
|
+
per_lang = {}
|
|
78
|
+
is_root = any(f in ROOT_MARKERS for f in files)
|
|
79
|
+
for f in files:
|
|
80
|
+
ext = os.path.splitext(f)[1].lower()
|
|
81
|
+
lang = LANG_BY_EXT.get(ext)
|
|
82
|
+
if not lang:
|
|
83
|
+
continue
|
|
84
|
+
loc = count_loc(os.path.join(repo, rel, f))
|
|
85
|
+
ls = lang_stats.setdefault(lang, {"lang": lang, "files": 0, "loc": 0})
|
|
86
|
+
ls["files"] += 1
|
|
87
|
+
ls["loc"] += loc
|
|
88
|
+
if lang in MODULE_LANGS:
|
|
89
|
+
pl = per_lang.setdefault(lang, [0, 0])
|
|
90
|
+
pl[0] += 1
|
|
91
|
+
pl[1] += loc
|
|
92
|
+
if per_lang:
|
|
93
|
+
dominant = max(per_lang.items(), key=lambda kv: kv[1][1])
|
|
94
|
+
modules.append({
|
|
95
|
+
"path": rel or "(root)",
|
|
96
|
+
"lang": dominant[0],
|
|
97
|
+
"files": sum(v[0] for v in per_lang.values()),
|
|
98
|
+
"loc": sum(v[1] for v in per_lang.values()),
|
|
99
|
+
"root": is_root,
|
|
100
|
+
"generated": bool(GENERATED_RE.search("/" + rel)),
|
|
101
|
+
})
|
|
102
|
+
languages = sorted(lang_stats.values(), key=lambda x: -x["loc"])
|
|
103
|
+
modules.sort(key=lambda m: -m["loc"])
|
|
104
|
+
return languages, modules
|
repokg/deps.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Dependency-edge extraction: internal import graphs for Go, Python, JS/TS.
|
|
2
|
+
|
|
3
|
+
Edges are directory -> directory, deduplicated with counts. Only imports that
|
|
4
|
+
resolve inside the repo are kept (external/third-party imports are ignored).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from .code import walk
|
|
13
|
+
|
|
14
|
+
GO_BLOCK_RE = re.compile(r"^import\s*\(\s*(.*?)\s*\)", re.S | re.M)
|
|
15
|
+
GO_SINGLE_RE = re.compile(r'^import\s+(?:\w+\s+)?"([^"]+)"', re.M)
|
|
16
|
+
GO_QUOTED_RE = re.compile(r'"([^"]+)"')
|
|
17
|
+
GO_MODULE_RE = re.compile(r"^module\s+(\S+)", re.M)
|
|
18
|
+
JS_IMPORT_RE = re.compile(
|
|
19
|
+
r"""(?:from\s+|require\(\s*|import\(\s*|^\s*import\s+)['"](\.{1,2}/[^'"]+)['"]""",
|
|
20
|
+
re.M)
|
|
21
|
+
JS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def collect(repo, tree=None):
|
|
25
|
+
"""Return [{"from": dir, "to": dir, "lang": lang, "count": n}] sorted by count.
|
|
26
|
+
|
|
27
|
+
tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo.
|
|
28
|
+
"""
|
|
29
|
+
counter = Counter()
|
|
30
|
+
if tree is None:
|
|
31
|
+
tree = {rel: files for rel, files in walk(repo)}
|
|
32
|
+
dirs = set(tree)
|
|
33
|
+
|
|
34
|
+
_go_edges(repo, tree, counter)
|
|
35
|
+
_py_edges(repo, tree, dirs, counter)
|
|
36
|
+
_js_edges(repo, tree, dirs, counter)
|
|
37
|
+
|
|
38
|
+
edges = [{"from": f or "(root)", "to": t or "(root)", "lang": lang, "count": n}
|
|
39
|
+
for (f, t, lang), n in counter.items()]
|
|
40
|
+
edges.sort(key=lambda e: -e["count"])
|
|
41
|
+
return edges
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read(repo, rel, name):
|
|
45
|
+
try:
|
|
46
|
+
with open(os.path.join(repo, rel, name), encoding="utf-8", errors="replace") as f:
|
|
47
|
+
return f.read()
|
|
48
|
+
except OSError:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# -- Go ----------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _go_edges(repo, tree, counter):
|
|
55
|
+
roots = {} # rel dir of go.mod -> module path
|
|
56
|
+
for rel, files in tree.items():
|
|
57
|
+
if "go.mod" in files:
|
|
58
|
+
m = GO_MODULE_RE.search(_read(repo, rel, "go.mod"))
|
|
59
|
+
if m:
|
|
60
|
+
roots[rel] = m.group(1)
|
|
61
|
+
if not roots:
|
|
62
|
+
return
|
|
63
|
+
for rel, files in tree.items():
|
|
64
|
+
root = _owning_root(rel, roots)
|
|
65
|
+
if root is None:
|
|
66
|
+
continue
|
|
67
|
+
modpath = roots[root]
|
|
68
|
+
for f in files:
|
|
69
|
+
if not f.endswith(".go") or f.endswith("_test.go"):
|
|
70
|
+
continue
|
|
71
|
+
src = _read(repo, rel, f)
|
|
72
|
+
imports = GO_SINGLE_RE.findall(src)
|
|
73
|
+
for block in GO_BLOCK_RE.findall(src):
|
|
74
|
+
imports.extend(GO_QUOTED_RE.findall(block))
|
|
75
|
+
for imp in imports:
|
|
76
|
+
if imp != modpath and not imp.startswith(modpath + "/"):
|
|
77
|
+
continue
|
|
78
|
+
sub = imp[len(modpath):].lstrip("/")
|
|
79
|
+
target = _norm(os.path.join(root, sub) if root else sub)
|
|
80
|
+
if target != rel and target in tree:
|
|
81
|
+
counter[(rel, target, "Go")] += 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _owning_root(rel, roots):
|
|
85
|
+
best = None
|
|
86
|
+
for root in roots:
|
|
87
|
+
if rel == root or root == "" or rel.startswith(root + "/"):
|
|
88
|
+
if best is None or len(root) > len(best):
|
|
89
|
+
best = root
|
|
90
|
+
return best
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# -- Python ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _py_edges(repo, tree, dirs, counter):
|
|
96
|
+
# Internal top-level packages: dirs at repo root or under src/ holding .py files.
|
|
97
|
+
pkg_map = {}
|
|
98
|
+
for rel in dirs:
|
|
99
|
+
parts = rel.split("/") if rel else []
|
|
100
|
+
if not parts:
|
|
101
|
+
continue
|
|
102
|
+
if len(parts) == 1 or (len(parts) == 2 and parts[0] == "src"):
|
|
103
|
+
if any(f.endswith(".py") for f in tree.get(rel, ())):
|
|
104
|
+
pkg_map[parts[-1]] = rel
|
|
105
|
+
for rel, files in tree.items():
|
|
106
|
+
for f in files:
|
|
107
|
+
if not f.endswith(".py"):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
node = ast.parse(_read(repo, rel, f))
|
|
111
|
+
except (SyntaxError, ValueError): # ValueError: null bytes on py<=3.11
|
|
112
|
+
continue
|
|
113
|
+
for stmt in ast.walk(node):
|
|
114
|
+
if isinstance(stmt, ast.Import):
|
|
115
|
+
for alias in stmt.names:
|
|
116
|
+
_py_edge(rel, alias.name, 0, pkg_map, dirs, counter)
|
|
117
|
+
elif isinstance(stmt, ast.ImportFrom):
|
|
118
|
+
_py_edge(rel, stmt.module or "", stmt.level, pkg_map, dirs, counter)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _py_edge(rel, module, level, pkg_map, dirs, counter):
|
|
122
|
+
if level: # relative import
|
|
123
|
+
base = rel
|
|
124
|
+
for _ in range(level - 1):
|
|
125
|
+
base = os.path.dirname(base)
|
|
126
|
+
target = _norm(os.path.join(base, *module.split("."))) if module else base
|
|
127
|
+
else:
|
|
128
|
+
head = module.split(".")[0]
|
|
129
|
+
if head not in pkg_map:
|
|
130
|
+
return
|
|
131
|
+
target = _norm(os.path.join(os.path.dirname(pkg_map[head]),
|
|
132
|
+
*module.split(".")))
|
|
133
|
+
target = _existing_dir(target, dirs)
|
|
134
|
+
if target is not None and target != rel:
|
|
135
|
+
counter[(rel, target, "Python")] += 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -- JS / TS -----------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _js_edges(repo, tree, dirs, counter):
|
|
141
|
+
for rel, files in tree.items():
|
|
142
|
+
for f in files:
|
|
143
|
+
if not f.endswith(JS_EXTS) or f.endswith(".d.ts"):
|
|
144
|
+
continue
|
|
145
|
+
for imp in JS_IMPORT_RE.findall(_read(repo, rel, f)):
|
|
146
|
+
target = _existing_dir(_norm(os.path.join(rel, imp)), dirs)
|
|
147
|
+
if target is not None and target != rel:
|
|
148
|
+
counter[(rel, target, "JS/TS")] += 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# -- helpers -----------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _norm(p):
|
|
154
|
+
p = os.path.normpath(p).replace(os.sep, "/")
|
|
155
|
+
return "" if p == "." else p
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _existing_dir(path, dirs):
|
|
159
|
+
"""Resolve an import target to a known repo dir (itself, or its parent
|
|
160
|
+
when the import points at a file)."""
|
|
161
|
+
if path in dirs:
|
|
162
|
+
return path
|
|
163
|
+
parent = _norm(os.path.dirname(path))
|
|
164
|
+
if parent in dirs:
|
|
165
|
+
return parent
|
|
166
|
+
return None
|
repokg/github.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""GitHub collectors (via `gh` CLI) and branch x PR classification."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
FIELDS = "number,title,state,headRefName,author,createdAt,mergedAt,closedAt,isDraft"
|
|
8
|
+
|
|
9
|
+
# Branch statuses:
|
|
10
|
+
# trunk / integration - the long-lived branches
|
|
11
|
+
# active - has an open PR
|
|
12
|
+
# merged - reachable from the default base (merge-commit merge)
|
|
13
|
+
# squash-merged - not an ancestor, but its PR was merged (squash/rebase)
|
|
14
|
+
# abandoned - only closed-without-merge PRs
|
|
15
|
+
# stale - no PR ever opened
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def collect(repo, limit=1000):
|
|
19
|
+
"""Return (prs, note). Degrades gracefully when gh is unavailable."""
|
|
20
|
+
if not shutil.which("gh"):
|
|
21
|
+
return [], "gh CLI not found; PR data skipped"
|
|
22
|
+
p = subprocess.run(
|
|
23
|
+
["gh", "pr", "list", "--state", "all", "--limit", str(limit), "--json", FIELDS],
|
|
24
|
+
cwd=repo, capture_output=True, text=True)
|
|
25
|
+
if p.returncode != 0:
|
|
26
|
+
return [], "gh failed: " + p.stderr.strip()[:200]
|
|
27
|
+
prs = []
|
|
28
|
+
for x in json.loads(p.stdout or "[]"):
|
|
29
|
+
prs.append({
|
|
30
|
+
"number": x["number"],
|
|
31
|
+
"title": x.get("title", "").strip(),
|
|
32
|
+
"state": x.get("state", ""),
|
|
33
|
+
"head": x.get("headRefName", ""),
|
|
34
|
+
"author": (x.get("author") or {}).get("login", ""),
|
|
35
|
+
"created": (x.get("createdAt") or "")[:10],
|
|
36
|
+
"merged": (x.get("mergedAt") or "")[:10],
|
|
37
|
+
"closed": (x.get("closedAt") or "")[:10],
|
|
38
|
+
"draft": bool(x.get("isDraft")),
|
|
39
|
+
})
|
|
40
|
+
prs.sort(key=lambda pr: pr["number"])
|
|
41
|
+
return prs, ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify(branches, prs, trunk, integration):
|
|
45
|
+
"""Set status + prs on every branch by cross-referencing PR head refs."""
|
|
46
|
+
by_head = {}
|
|
47
|
+
for pr in prs:
|
|
48
|
+
by_head.setdefault(pr["head"], []).append(pr)
|
|
49
|
+
for b in branches:
|
|
50
|
+
linked = by_head.get(b["name"], [])
|
|
51
|
+
b["prs"] = [pr["number"] for pr in linked]
|
|
52
|
+
states = {pr["state"] for pr in linked}
|
|
53
|
+
if b["name"] == trunk:
|
|
54
|
+
b["status"] = "trunk"
|
|
55
|
+
elif integration and b["name"] == integration:
|
|
56
|
+
b["status"] = "integration"
|
|
57
|
+
elif "OPEN" in states:
|
|
58
|
+
b["status"] = "active"
|
|
59
|
+
elif b.get("merged_ancestry"):
|
|
60
|
+
b["status"] = "merged"
|
|
61
|
+
elif "MERGED" in states:
|
|
62
|
+
b["status"] = "squash-merged"
|
|
63
|
+
elif "CLOSED" in states:
|
|
64
|
+
b["status"] = "abandoned"
|
|
65
|
+
else:
|
|
66
|
+
b["status"] = "stale"
|
repokg/gitinfo.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Git collectors: branches, trunk/integration detection, merge classification, contributors."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
INTEGRATION_CANDIDATES = ("staging", "develop", "dev", "next", "canary")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(repo, *args):
|
|
10
|
+
p = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True)
|
|
11
|
+
if p.returncode != 0:
|
|
12
|
+
raise RuntimeError("git %s: %s" % (" ".join(args), p.stderr.strip()))
|
|
13
|
+
return p.stdout.strip()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def try_run(repo, *args):
|
|
17
|
+
try:
|
|
18
|
+
return run(repo, *args)
|
|
19
|
+
except (RuntimeError, OSError):
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def collect(repo):
|
|
24
|
+
"""Return (repo_info, branches). Each branch dict carries merged_ancestry/ahead
|
|
25
|
+
against the default base (integration branch if present, else trunk)."""
|
|
26
|
+
top = run(repo, "rev-parse", "--show-toplevel")
|
|
27
|
+
remote = try_run(repo, "remote", "get-url", "origin")
|
|
28
|
+
|
|
29
|
+
fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)%09%(contents:subject)"
|
|
30
|
+
raw = try_run(repo, "for-each-ref", "refs/remotes/origin", "--format=" + fmt)
|
|
31
|
+
use_remote = bool(raw)
|
|
32
|
+
if not use_remote:
|
|
33
|
+
raw = try_run(repo, "for-each-ref", "refs/heads", "--format=" + fmt)
|
|
34
|
+
|
|
35
|
+
branches = []
|
|
36
|
+
for line in raw.splitlines():
|
|
37
|
+
parts = line.split("\t", 3)
|
|
38
|
+
while len(parts) < 4:
|
|
39
|
+
parts.append("")
|
|
40
|
+
ref, date, author, subject = parts
|
|
41
|
+
name = ref[len("origin/"):] if use_remote and ref.startswith("origin/") else ref
|
|
42
|
+
if name in ("HEAD", "") or ref == "origin":
|
|
43
|
+
continue
|
|
44
|
+
branches.append({
|
|
45
|
+
"name": name, "ref": ref, "date": date,
|
|
46
|
+
"author": author, "subject": subject,
|
|
47
|
+
})
|
|
48
|
+
names = {b["name"] for b in branches}
|
|
49
|
+
|
|
50
|
+
trunk = _detect_trunk(repo, names, use_remote)
|
|
51
|
+
integration = next((c for c in INTEGRATION_CANDIDATES if c in names and c != trunk), "")
|
|
52
|
+
base = integration or trunk
|
|
53
|
+
base_ref = ("origin/" + base) if (use_remote and base) else base
|
|
54
|
+
prefix = "refs/remotes/origin" if use_remote else "refs/heads"
|
|
55
|
+
|
|
56
|
+
merged_refs, ahead_map = set(), {}
|
|
57
|
+
if base_ref:
|
|
58
|
+
out = try_run(repo, "for-each-ref", prefix, "--merged=" + base_ref,
|
|
59
|
+
"--format=%(refname:short)")
|
|
60
|
+
merged_refs = set(out.splitlines())
|
|
61
|
+
# One call for all ahead counts (git >= 2.41); fall back per-branch below.
|
|
62
|
+
out = try_run(repo, "for-each-ref", prefix,
|
|
63
|
+
"--format=%(refname:short)%09%(ahead-behind:" + base_ref + ")")
|
|
64
|
+
for line in out.splitlines():
|
|
65
|
+
parts = line.split("\t")
|
|
66
|
+
if len(parts) == 2:
|
|
67
|
+
nums = parts[1].split()
|
|
68
|
+
if len(nums) == 2 and nums[0].isdigit():
|
|
69
|
+
ahead_map[parts[0]] = int(nums[0])
|
|
70
|
+
|
|
71
|
+
for b in branches:
|
|
72
|
+
b["merged_ancestry"] = b["ref"] in merged_refs
|
|
73
|
+
if b["name"] in (trunk, integration) or b["merged_ancestry"] or not base_ref:
|
|
74
|
+
b["ahead"] = 0
|
|
75
|
+
elif b["ref"] in ahead_map:
|
|
76
|
+
b["ahead"] = ahead_map[b["ref"]]
|
|
77
|
+
else:
|
|
78
|
+
n = try_run(repo, "rev-list", "--count", "%s..%s" % (base_ref, b["ref"]))
|
|
79
|
+
b["ahead"] = int(n) if n.isdigit() else 0
|
|
80
|
+
|
|
81
|
+
trunk_ref = ("origin/" + trunk) if use_remote and trunk in names else trunk
|
|
82
|
+
contributors = []
|
|
83
|
+
for line in try_run(repo, "shortlog", "-sn", "--no-merges", trunk_ref).splitlines():
|
|
84
|
+
parts = line.strip().split("\t", 1)
|
|
85
|
+
if len(parts) == 2 and parts[0].strip().isdigit():
|
|
86
|
+
contributors.append({"name": parts[1].strip(), "commits": int(parts[0])})
|
|
87
|
+
|
|
88
|
+
count = try_run(repo, "rev-list", "--count", trunk_ref)
|
|
89
|
+
roots = try_run(repo, "log", "--max-parents=0", "--format=%cs", trunk_ref).splitlines()
|
|
90
|
+
|
|
91
|
+
info = {
|
|
92
|
+
"path": top,
|
|
93
|
+
"name": _repo_name(remote, top),
|
|
94
|
+
"remote": remote,
|
|
95
|
+
"head": try_run(repo, "rev-parse", "HEAD"),
|
|
96
|
+
"trunk": trunk,
|
|
97
|
+
"integration": integration,
|
|
98
|
+
"default_base": base,
|
|
99
|
+
"first_commit": roots[-1] if roots else "",
|
|
100
|
+
"commit_count": int(count) if count.isdigit() else 0,
|
|
101
|
+
"contributors": contributors,
|
|
102
|
+
}
|
|
103
|
+
return info, branches
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _detect_trunk(repo, names, use_remote):
|
|
107
|
+
if use_remote:
|
|
108
|
+
head = try_run(repo, "symbolic-ref", "refs/remotes/origin/HEAD")
|
|
109
|
+
if head:
|
|
110
|
+
return head.rsplit("/", 1)[-1]
|
|
111
|
+
for cand in ("main", "master"):
|
|
112
|
+
if cand in names:
|
|
113
|
+
return cand
|
|
114
|
+
cur = try_run(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
115
|
+
if cur and cur != "HEAD":
|
|
116
|
+
return cur
|
|
117
|
+
return sorted(names)[0] if names else ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _repo_name(remote, top):
|
|
121
|
+
if remote:
|
|
122
|
+
tail = remote.rstrip("/").rsplit("/", 1)[-1]
|
|
123
|
+
return tail[:-4] if tail.endswith(".git") else tail
|
|
124
|
+
return os.path.basename(top)
|