prgenius-core 0.7.8__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.
- prgenius/__init__.py +21 -0
- prgenius/__main__.py +3 -0
- prgenius/cli.py +149 -0
- prgenius/mcp.py +103 -0
- prgenius/parser.py +225 -0
- prgenius/py.typed +0 -0
- prgenius_core-0.7.8.dist-info/METADATA +211 -0
- prgenius_core-0.7.8.dist-info/RECORD +11 -0
- prgenius_core-0.7.8.dist-info/WHEEL +5 -0
- prgenius_core-0.7.8.dist-info/entry_points.txt +2 -0
- prgenius_core-0.7.8.dist-info/top_level.txt +1 -0
prgenius/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""prgenius — Evidence-backed lookup for big-repo PR contributions.
|
|
2
|
+
|
|
3
|
+
Local-only. Zero runtime ops. Stdlib-only (PyYAML opt-in via extras).
|
|
4
|
+
|
|
5
|
+
Public surface:
|
|
6
|
+
- load() — parse one markdown file into a dict (frontmatter + body)
|
|
7
|
+
- iter_profiles(repo_root) — yield Repo Profile dicts
|
|
8
|
+
- iter_case_studies(repo_root) — yield PR Case Study dicts
|
|
9
|
+
- profile_get(repo_root, "<org>/<repo>") — look up a single profile
|
|
10
|
+
- schema_info() — return supported OKF schema versions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.7.8"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"load",
|
|
17
|
+
"iter_profiles",
|
|
18
|
+
"iter_case_studies",
|
|
19
|
+
"profile_get",
|
|
20
|
+
"schema_info",
|
|
21
|
+
]
|
prgenius/__main__.py
ADDED
prgenius/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""CLI entry point for `prgenius`.
|
|
2
|
+
|
|
3
|
+
Usage examples (run from repo root):
|
|
4
|
+
python3 -m prgenius profile get astral-sh/uv
|
|
5
|
+
python3 -m prgenius case list --status=open
|
|
6
|
+
python3 -m prgenius schema info
|
|
7
|
+
python3 -m prgenius dump --format=ndjson > cases.ndjson
|
|
8
|
+
python3 -m prgenius mcp serve # stdio MCP shell
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .parser import (
|
|
18
|
+
iter_profiles,
|
|
19
|
+
iter_case_studies,
|
|
20
|
+
profile_get,
|
|
21
|
+
schema_info,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
REPO_ROOT_DEFAULT = Path(__file__).resolve().parents[3] # up to big-repo-pr-knowledge
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_repo_root(args) -> Path:
|
|
29
|
+
if args.repo_root:
|
|
30
|
+
return Path(args.repo_root).resolve()
|
|
31
|
+
return REPO_ROOT_DEFAULT
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_profile_get(args) -> int:
|
|
35
|
+
p = profile_get(_get_repo_root(args), args.repo)
|
|
36
|
+
if p is None:
|
|
37
|
+
print(f"profile not found: {args.repo}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
out = {
|
|
40
|
+
"path": p["path"],
|
|
41
|
+
"folder": p["folder"],
|
|
42
|
+
"frontmatter": p["frontmatter"],
|
|
43
|
+
"first_lines": p["body"].splitlines()[:30],
|
|
44
|
+
}
|
|
45
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_case_list(args) -> int:
|
|
50
|
+
rows = []
|
|
51
|
+
for c in iter_case_studies(_get_repo_root(args)):
|
|
52
|
+
fm = c["frontmatter"]
|
|
53
|
+
fs = fm.get("final_status", "?")
|
|
54
|
+
if args.status and fs != args.status:
|
|
55
|
+
continue
|
|
56
|
+
rows.append({
|
|
57
|
+
"folder": c["folder"],
|
|
58
|
+
"pr_number": fm.get("pr_number"),
|
|
59
|
+
"pr_url": fm.get("pr_url"),
|
|
60
|
+
"final_status": fs,
|
|
61
|
+
"schema_version": fm.get("schema_version", "legacy v0.1"),
|
|
62
|
+
"verified_at": fm.get("verified_at"),
|
|
63
|
+
})
|
|
64
|
+
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_schema_info(_args) -> int:
|
|
69
|
+
print(json.dumps(schema_info(), indent=2, ensure_ascii=False))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_dump(args) -> int:
|
|
74
|
+
"""Dump everything in NDJSON (one PR per line) for benchmark use."""
|
|
75
|
+
root = _get_repo_root(args)
|
|
76
|
+
for c in iter_case_studies(root):
|
|
77
|
+
fm = c["frontmatter"]
|
|
78
|
+
record = {
|
|
79
|
+
"folder": c["folder"],
|
|
80
|
+
"pr_file": c["pr_file"],
|
|
81
|
+
"pr_number": fm.get("pr_number"),
|
|
82
|
+
"pr_url": fm.get("pr_url"),
|
|
83
|
+
"repo": fm.get("repo"),
|
|
84
|
+
"final_status": fm.get("final_status"),
|
|
85
|
+
"opened_at": fm.get("opened_at"),
|
|
86
|
+
"merged_at": fm.get("merged_at"),
|
|
87
|
+
"closed_at": fm.get("closed_at"),
|
|
88
|
+
"schema_version": fm.get("schema_version", "legacy v0.1"),
|
|
89
|
+
"verified_at": fm.get("verified_at"),
|
|
90
|
+
"evidence_urls": fm.get("evidence_urls", []),
|
|
91
|
+
"confidence": fm.get("confidence"),
|
|
92
|
+
"rounds": fm.get("rounds", []),
|
|
93
|
+
"close_decision": fm.get("close_decision"),
|
|
94
|
+
}
|
|
95
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_mcp_serve(args) -> int:
|
|
100
|
+
"""stdio MCP shell. See prgenius/mcp.py for the small facade.
|
|
101
|
+
|
|
102
|
+
Pass --repo-root to point the MCP server at a non-default location
|
|
103
|
+
(e.g. an isolated worktree or a forked copy of the knowledge base).
|
|
104
|
+
"""
|
|
105
|
+
from .mcp import serve
|
|
106
|
+
repo_root = _get_repo_root(args)
|
|
107
|
+
return serve(repo_root=repo_root)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: list[str] | None = None) -> int:
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
prog="prgenius",
|
|
113
|
+
description="Evidence-backed lookup for big-repo PR contributions.",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument("--repo-root", help="Path to big-repo-pr-knowledge (default: auto-detect)")
|
|
116
|
+
parser.add_argument("--version", action="version", version=f"prgenius {__version__}")
|
|
117
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
118
|
+
|
|
119
|
+
p_get = sub.add_parser("profile", help="Profile operations")
|
|
120
|
+
p_get_sub = p_get.add_subparsers(dest="profile_cmd", required=True)
|
|
121
|
+
pp = p_get_sub.add_parser("get", help="Get one profile")
|
|
122
|
+
pp.add_argument("repo", help="org/name (e.g. astral-sh/uv)")
|
|
123
|
+
pp.set_defaults(func=cmd_profile_get)
|
|
124
|
+
|
|
125
|
+
c_list = sub.add_parser("case", help="Case study operations")
|
|
126
|
+
c_list_sub = c_list.add_subparsers(dest="case_cmd", required=True)
|
|
127
|
+
cl = c_list_sub.add_parser("list", help="List case studies")
|
|
128
|
+
cl.add_argument("--status", help="Filter by final_status (open/merged/...)")
|
|
129
|
+
cl.set_defaults(func=cmd_case_list)
|
|
130
|
+
|
|
131
|
+
s_info = sub.add_parser("schema", help="Schema info")
|
|
132
|
+
s_info_sub = s_info.add_subparsers(dest="schema_cmd", required=True)
|
|
133
|
+
si = s_info_sub.add_parser("info", help="Show supported schema versions")
|
|
134
|
+
si.set_defaults(func=cmd_schema_info)
|
|
135
|
+
|
|
136
|
+
dmp = sub.add_parser("dump", help="NDJSON dump of all cases (for benchmarks)")
|
|
137
|
+
dmp.set_defaults(func=cmd_dump)
|
|
138
|
+
|
|
139
|
+
m_serve = sub.add_parser("mcp", help="MCP server (stdio)")
|
|
140
|
+
m_serve_sub = m_serve.add_subparsers(dest="mcp_cmd", required=True)
|
|
141
|
+
ms = m_serve_sub.add_parser("serve", help="Run stdio MCP shell")
|
|
142
|
+
ms.set_defaults(func=cmd_mcp_serve)
|
|
143
|
+
|
|
144
|
+
args = parser.parse_args(argv)
|
|
145
|
+
return args.func(args)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
sys.exit(main())
|
prgenius/mcp.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""stdio MCP shell for prgenius — a thin façade.
|
|
2
|
+
|
|
3
|
+
This module is optional: only loaded if `mcp` is installed (the only runtime
|
|
4
|
+
dependency we ever take). Calling `python3 -m prgenius mcp serve` starts a
|
|
5
|
+
stdio MCP server that exposes the prgenius lookup surface to local agents
|
|
6
|
+
(Cursor/Cline/Claude Code). No network, no auth, no rate-limiting.
|
|
7
|
+
|
|
8
|
+
Promoted tools (intentionally small — ACD rule "first to simplify wins"):
|
|
9
|
+
- get_repo_profile(repo) → returns one Repo Profile dict
|
|
10
|
+
- list_open_prs() → list of PRs with final_status=open
|
|
11
|
+
- get_case_study(repo, pr_number) → one PR Case Study
|
|
12
|
+
- schema_info() → enumerated schema versions
|
|
13
|
+
|
|
14
|
+
This file is ~70 lines (one per tool + boilerplate). Anyone reading it should
|
|
15
|
+
be able to verify the surface and the path-resolution logic in under 5 min.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# mcp SDK: only imported at serve() time so the rest of the package stays stdlib.
|
|
23
|
+
REPO_ROOT_DEFAULT = Path(__file__).resolve().parents[3] # up to big-repo-pr-knowledge
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_tools(repo_root: Path | None = None):
|
|
27
|
+
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
from .parser import (
|
|
29
|
+
iter_case_studies,
|
|
30
|
+
profile_get,
|
|
31
|
+
schema_info,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
mcp = FastMCP(name="prgenius", instructions=(
|
|
35
|
+
"Evidence-backed lookup for big-repo PR contributions. "
|
|
36
|
+
"Local stdio; no network calls."
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
rr = repo_root or REPO_ROOT_DEFAULT
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def get_repo_profile(repo: str) -> dict:
|
|
43
|
+
"""Return one Repo Profile by `org/name` (e.g. astral-sh/uv)."""
|
|
44
|
+
p = profile_get(rr, repo)
|
|
45
|
+
if p is None:
|
|
46
|
+
return {"error": f"profile not found: {repo}"}
|
|
47
|
+
return p["frontmatter"]
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def list_open_prs() -> list:
|
|
51
|
+
"""List every PR Case Study with final_status=open."""
|
|
52
|
+
out = []
|
|
53
|
+
for c in iter_case_studies(rr):
|
|
54
|
+
fm = c["frontmatter"]
|
|
55
|
+
if fm.get("final_status") == "open":
|
|
56
|
+
out.append({
|
|
57
|
+
"repo": fm.get("repo"),
|
|
58
|
+
"pr_number": fm.get("pr_number"),
|
|
59
|
+
"pr_url": fm.get("pr_url"),
|
|
60
|
+
"folder": c["folder"],
|
|
61
|
+
"schema_version": fm.get("schema_version", "legacy v0.1"),
|
|
62
|
+
"verified_at": fm.get("verified_at"),
|
|
63
|
+
})
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def get_case_study(repo: str, pr_number: int) -> dict:
|
|
68
|
+
"""Return one PR Case Study by `org/name` + `pr_number`."""
|
|
69
|
+
for c in iter_case_studies(rr):
|
|
70
|
+
fm = c["frontmatter"]
|
|
71
|
+
if (
|
|
72
|
+
fm.get("repo", "").strip("/").lower() == repo.strip("/").lower()
|
|
73
|
+
and str(fm.get("pr_number")) == str(pr_number)
|
|
74
|
+
):
|
|
75
|
+
return {
|
|
76
|
+
"frontmatter": fm,
|
|
77
|
+
"body": c["body"],
|
|
78
|
+
"path": c["path"],
|
|
79
|
+
}
|
|
80
|
+
return {"error": f"case study not found: {repo}#{pr_number}"}
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def schema_info() -> dict:
|
|
84
|
+
"""Return supported schema versions + enum values."""
|
|
85
|
+
return schema_info()
|
|
86
|
+
|
|
87
|
+
return mcp
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def serve(repo_root: Path | None = None) -> int:
|
|
91
|
+
"""Run stdio MCP server. Blocks until the host disconnects.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
repo_root: Override the knowledge base location. If None, uses
|
|
95
|
+
the auto-detected default (parents[3] of this file).
|
|
96
|
+
"""
|
|
97
|
+
mcp = _load_tools(repo_root=repo_root)
|
|
98
|
+
mcp.run(transport="stdio")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(serve())
|
prgenius/parser.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Markdown frontmatter parsing — pure-stdlib YAML-subset.
|
|
2
|
+
|
|
3
|
+
Goals:
|
|
4
|
+
- Zero hard deps (no PyYAML) — package works after `pip install prgenius-core`
|
|
5
|
+
- Just enough fidelity for OKF v0.1 / rounds v0.5.0 / v0.7.0 frontmatter
|
|
6
|
+
- Honest: returns the AST we can parse; preserves raw text for the rest
|
|
7
|
+
|
|
8
|
+
Strategy: 2-pass indent-stack parser.
|
|
9
|
+
- Pass 1: collect (line, indent, raw_line)
|
|
10
|
+
- Pass 2: walk lines; push/pop stack based on indent depth
|
|
11
|
+
|
|
12
|
+
Limitations:
|
|
13
|
+
- top-level key: value (string/int/bool/null/list-inline)
|
|
14
|
+
- 2-space indent for nested mapping
|
|
15
|
+
- `- item` lists (with optional inline key: value on first line)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_FM_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(path: str | Path) -> dict:
|
|
27
|
+
"""Load a markdown file: return {frontmatter, body, path}."""
|
|
28
|
+
p = Path(path)
|
|
29
|
+
text = p.read_text(encoding="utf-8")
|
|
30
|
+
fm_match = _FM_RE.match(text)
|
|
31
|
+
if not fm_match:
|
|
32
|
+
return {"frontmatter": {}, "body": text, "path": str(p)}
|
|
33
|
+
fm_text = fm_match.group(1)
|
|
34
|
+
body = text[fm_match.end():].lstrip("\n")
|
|
35
|
+
return {
|
|
36
|
+
"frontmatter": parse_frontmatter(fm_text),
|
|
37
|
+
"body": body,
|
|
38
|
+
"path": str(p),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unquote(s: str) -> str:
|
|
43
|
+
s = s.strip()
|
|
44
|
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"):
|
|
45
|
+
return s[1:-1]
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _coerce(v: str):
|
|
50
|
+
"""Coerce a scalar string to a Python value."""
|
|
51
|
+
if v == "" or v in ("null", "~"):
|
|
52
|
+
return None
|
|
53
|
+
if v == "true":
|
|
54
|
+
return True
|
|
55
|
+
if v == "false":
|
|
56
|
+
return False
|
|
57
|
+
if v.startswith("[") and v.endswith("]"):
|
|
58
|
+
inner = v[1:-1].strip()
|
|
59
|
+
if not inner:
|
|
60
|
+
return []
|
|
61
|
+
return [_coerce(x.strip()) for x in inner.split(",")]
|
|
62
|
+
if re.match(r"^-?\d+$", v):
|
|
63
|
+
return int(v)
|
|
64
|
+
if re.match(r"^-?\d+\.\d+$", v):
|
|
65
|
+
return float(v)
|
|
66
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
|
67
|
+
return v[1:-1]
|
|
68
|
+
return v
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _last_list_key(d: dict):
|
|
72
|
+
for k in reversed(d.keys()):
|
|
73
|
+
if isinstance(d[k], list):
|
|
74
|
+
return k
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_lists(node):
|
|
79
|
+
"""Recursively replace `{'_': [items]}` or `{'_items': [items]}` (parser quirk)
|
|
80
|
+
with the bare list.
|
|
81
|
+
"""
|
|
82
|
+
if isinstance(node, dict):
|
|
83
|
+
keys = list(node.keys())
|
|
84
|
+
if (
|
|
85
|
+
len(keys) == 1
|
|
86
|
+
and keys[0] in ("_", "_items")
|
|
87
|
+
and isinstance(node[keys[0]], list)
|
|
88
|
+
):
|
|
89
|
+
return [_normalize_lists(x) for x in node[keys[0]]]
|
|
90
|
+
return {k: _normalize_lists(v) for k, v in node.items()}
|
|
91
|
+
if isinstance(node, list):
|
|
92
|
+
return [_normalize_lists(x) for x in node]
|
|
93
|
+
return node
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_frontmatter(text: str) -> dict:
|
|
97
|
+
"""Parse a YAML-subset frontmatter block.
|
|
98
|
+
|
|
99
|
+
Strategy: walk lines, push/pop an indent-aware stack. Lists get attached
|
|
100
|
+
to the most recent dict-key (the "current list"). After the walk we
|
|
101
|
+
normalize any `{'_': [...]}` quirk wrappers to plain lists.
|
|
102
|
+
"""
|
|
103
|
+
tokens = []
|
|
104
|
+
for raw in text.splitlines():
|
|
105
|
+
if not raw.strip():
|
|
106
|
+
continue
|
|
107
|
+
if raw.lstrip().startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
110
|
+
tokens.append((indent, raw.strip()))
|
|
111
|
+
|
|
112
|
+
root: dict = {}
|
|
113
|
+
# stack of (indent_level, container); root container indent = -1
|
|
114
|
+
stack = [(-1, root)]
|
|
115
|
+
|
|
116
|
+
for indent, line in tokens:
|
|
117
|
+
# pop until stack top has smaller indent than current
|
|
118
|
+
while len(stack) > 1 and stack[-1][0] >= indent:
|
|
119
|
+
stack.pop()
|
|
120
|
+
parent_indent, parent = stack[-1]
|
|
121
|
+
|
|
122
|
+
if line.startswith("- "):
|
|
123
|
+
content = line[2:].strip()
|
|
124
|
+
if not isinstance(parent, dict):
|
|
125
|
+
# shouldn't happen in our schema
|
|
126
|
+
continue
|
|
127
|
+
last_key = _last_list_key(parent)
|
|
128
|
+
if last_key is None:
|
|
129
|
+
parent["_items"] = []
|
|
130
|
+
current_list = parent["_items"]
|
|
131
|
+
last_key = "_items"
|
|
132
|
+
else:
|
|
133
|
+
current_list = parent[last_key]
|
|
134
|
+
|
|
135
|
+
if ":" in content and not content.startswith(('"', "'")):
|
|
136
|
+
k, _, v = content.partition(":")
|
|
137
|
+
k = _unquote(k.strip())
|
|
138
|
+
v = v.strip()
|
|
139
|
+
item: dict = {k: _coerce(v)}
|
|
140
|
+
current_list.append(item)
|
|
141
|
+
stack.append((indent, item))
|
|
142
|
+
else:
|
|
143
|
+
current_list.append(_coerce(content))
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if ":" not in line:
|
|
147
|
+
continue
|
|
148
|
+
k, _, v = line.partition(":")
|
|
149
|
+
k = _unquote(k.strip())
|
|
150
|
+
v = v.strip()
|
|
151
|
+
|
|
152
|
+
if not isinstance(parent, dict):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if v == "":
|
|
156
|
+
new = {}
|
|
157
|
+
parent[k] = new
|
|
158
|
+
stack.append((indent, new))
|
|
159
|
+
else:
|
|
160
|
+
parent[k] = _coerce(v)
|
|
161
|
+
|
|
162
|
+
return _normalize_lists(root)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------- higher-level iterators ----------
|
|
166
|
+
|
|
167
|
+
def iter_profiles(repo_root: str | Path) -> Iterator[dict]:
|
|
168
|
+
"""Yield each Repo Profile dict under `<repo_root>/<folder>/index.md`."""
|
|
169
|
+
root = Path(repo_root)
|
|
170
|
+
skip = {
|
|
171
|
+
"anti-patterns", "misakanet-50", ".github", "docs",
|
|
172
|
+
"scripts", "prgenius", "__pycache__", ".git",
|
|
173
|
+
"federation.yaml",
|
|
174
|
+
}
|
|
175
|
+
for sub in sorted(root.iterdir()):
|
|
176
|
+
if not sub.is_dir() or sub.name in skip or sub.name.startswith("."):
|
|
177
|
+
continue
|
|
178
|
+
idx = sub / "index.md"
|
|
179
|
+
if not idx.exists():
|
|
180
|
+
continue
|
|
181
|
+
loaded = load(idx)
|
|
182
|
+
if loaded["frontmatter"].get("type") == "Repo Profile":
|
|
183
|
+
loaded["folder"] = sub.name
|
|
184
|
+
yield loaded
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def iter_case_studies(repo_root: str | Path) -> Iterator[dict]:
|
|
188
|
+
"""Yield each PR Case Study dict under repo root."""
|
|
189
|
+
root = Path(repo_root)
|
|
190
|
+
for path in sorted(root.rglob("pr-*.md")):
|
|
191
|
+
try:
|
|
192
|
+
loaded = load(path)
|
|
193
|
+
except Exception:
|
|
194
|
+
continue
|
|
195
|
+
if loaded["frontmatter"].get("type") == "PR Case Study":
|
|
196
|
+
loaded["folder"] = path.parent.name
|
|
197
|
+
loaded["pr_file"] = path.name
|
|
198
|
+
yield loaded
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def profile_get(repo_root: str | Path, repo: str) -> dict | None:
|
|
202
|
+
"""Look up a Repo Profile by `org/name`. None if missing."""
|
|
203
|
+
target = repo.strip("/").lower()
|
|
204
|
+
target_folder = target.replace("/", "-")
|
|
205
|
+
for profile in iter_profiles(repo_root):
|
|
206
|
+
if profile["folder"].lower() == target_folder:
|
|
207
|
+
return profile
|
|
208
|
+
if profile["frontmatter"].get("repo", "").strip("/").lower() == target:
|
|
209
|
+
return profile
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def schema_info() -> dict:
|
|
214
|
+
return {
|
|
215
|
+
"schema_versions": ["rounds v0.5.0", "rounds v0.7.0 (BC over v0.5.0)"],
|
|
216
|
+
"delta_kinds": ["code_change", "no_code_change", "unknown"],
|
|
217
|
+
"close_decision_status": ["pending", "close", "keep_open", "merged", "superseded"],
|
|
218
|
+
"evidence_fields_round_level": ["verified_at", "evidence_urls", "confidence"],
|
|
219
|
+
"evidence_fields_case_level": ["verified_at", "evidence_urls", "confidence"],
|
|
220
|
+
"confidence_values": ["high", "medium", "low"],
|
|
221
|
+
"action_enum": [
|
|
222
|
+
"open", "amend", "bot_review", "human_review",
|
|
223
|
+
"check_in", "bump", "close", "merge", "decision",
|
|
224
|
+
],
|
|
225
|
+
}
|
prgenius/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prgenius-core
|
|
3
|
+
Version: 0.7.8
|
|
4
|
+
Summary: Evidence-backed lookup for big-repo PR contributions. Local-only, zero-runtime, stdlib-first. Renamed from `prgenius` to avoid PyPI name collision with an unrelated 2024 GPT-3 PR-description tool. Distribution renamed again in v0.7.8: `prgenius-kb` → `prgenius-core` (aligned with `misakanet-core` convention).
|
|
5
|
+
Author: pr-genius contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zsxh1990/pr-genius
|
|
8
|
+
Project-URL: Issues, https://github.com/zsxh1990/pr-genius/issues
|
|
9
|
+
Keywords: okf,pr-knowledge,agent-readable,contribution-intelligence,evidence
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Provides-Extra: mcp
|
|
21
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: build; extra == "dev"
|
|
24
|
+
Requires-Dist: twine; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
type: Schema Reference
|
|
29
|
+
title: prgenius
|
|
30
|
+
description: Evidence-backed lookup library for big-repo PR contributions — stdlib-first CLI + stdio MCP shell.
|
|
31
|
+
version: 0.7.0
|
|
32
|
+
created: 2026-07-04
|
|
33
|
+
updated: 2026-07-05
|
|
34
|
+
author: zsxh1990
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
# prgenius
|
|
38
|
+
|
|
39
|
+
**Evidence-backed lookup for big-repo PR contributions. Local-only. Stdlib-first.**
|
|
40
|
+
|
|
41
|
+
A pure-Python (zero hard deps) library + CLI that reads the markdown knowledge
|
|
42
|
+
base in this repo and exposes it through structured tool calls.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install prgenius-core
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or run from a checkout:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd prgenius
|
|
54
|
+
PYTHONPATH=src python3 -m prgenius --version
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Important: PyPI package is the *interface*, not the data
|
|
58
|
+
|
|
59
|
+
The PyPI wheel ships only the Python code (`prgenius/` package).
|
|
60
|
+
**The knowledge base (profile markdown, case studies, OKF schemas) lives
|
|
61
|
+
in the GitHub repo** at <https://github.com/zsxh1990/pr-genius> and is
|
|
62
|
+
**not** bundled into the wheel. To use `prgenius-core` after `pip install`,
|
|
63
|
+
point it at a checkout of the knowledge base:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/zsxh1990/pr-genius
|
|
67
|
+
prgenius-core --repo-root ./pr-genius profile get astral-sh/uv
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If you cloned a specific tag/commit, the package's `__version__` should
|
|
71
|
+
match (e.g. v0.7.7 ↔ `prgenius-core==0.7.7`).
|
|
72
|
+
|
|
73
|
+
### Optional: MCP server
|
|
74
|
+
|
|
75
|
+
The MCP entry point (`prgenius-core mcp serve`) requires the `mcp` package:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install "prgenius-core[mcp]"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 1) Schema reference
|
|
85
|
+
PYTHONPATH=src python3 -m prgenius schema info
|
|
86
|
+
|
|
87
|
+
# 2) Look up one repo
|
|
88
|
+
PYTHONPATH=src python3 -m prgenius profile get astral-sh/uv
|
|
89
|
+
|
|
90
|
+
# 3) List currently-open case studies
|
|
91
|
+
PYTHONPATH=src python3 -m prgenius case list --status=open
|
|
92
|
+
|
|
93
|
+
# 4) NDJSON dump of every case study (for benchmarks / agent context)
|
|
94
|
+
PYTHONPATH=src python3 -m prgenius dump > cases.ndjson
|
|
95
|
+
|
|
96
|
+
# 5) Run as stdio MCP server (for Cursor/Cline/Claude Code)
|
|
97
|
+
PYTHONPATH=src python3 -m prgenius mcp serve
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Programmatic use
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from prgenius.parser import profile_get, iter_case_studies, schema_info
|
|
104
|
+
|
|
105
|
+
prof = profile_get("path/to/repo_root", "astral-sh/uv")
|
|
106
|
+
print(prof["frontmatter"]["title"])
|
|
107
|
+
|
|
108
|
+
for case in iter_case_studies("path/to/repo_root"):
|
|
109
|
+
fm = case["frontmatter"]
|
|
110
|
+
if fm.get("final_status") == "open":
|
|
111
|
+
print(fm.get("pr_number"), fm.get("repo"))
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## What's exposed
|
|
115
|
+
|
|
116
|
+
| Tool | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `prgenius profile get <org/name>` | One Repo Profile (frontmatter + first lines) |
|
|
119
|
+
| `prgenius case list [--status=...]` | All PR Case Study rows |
|
|
120
|
+
| `prgenius schema info` | Supported schema versions + enums |
|
|
121
|
+
| `prgenius dump` | NDJSON dump (one case per line) |
|
|
122
|
+
| `prgenius mcp serve` | Stdio MCP server (4 tools) |
|
|
123
|
+
|
|
124
|
+
## MCP surface (when `mcp` is installed)
|
|
125
|
+
|
|
126
|
+
The MCP shell exposes 4 tools to local agents:
|
|
127
|
+
|
|
128
|
+
- `get_repo_profile(repo)` — one Profile dict
|
|
129
|
+
- `list_open_prs()` — currently-open PRs
|
|
130
|
+
- `get_case_study(repo, pr_number)` — one Case Study
|
|
131
|
+
- `schema_info()` — schema versions + enums
|
|
132
|
+
|
|
133
|
+
No network, no auth, no rate-limiting. Agent calls go through stdio only.
|
|
134
|
+
|
|
135
|
+
### `--repo-root` flag
|
|
136
|
+
|
|
137
|
+
The MCP server defaults to a path computed from its install location
|
|
138
|
+
(`<package>/../..` × 3 to reach the knowledge base). When that path is
|
|
139
|
+
wrong (editable install, venv, worktree, fork), pass `--repo-root`:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python3 -m prgenius --repo-root /path/to/big-repo-pr-knowledge mcp serve
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Wiring into Cursor / Claude Code / Cline
|
|
146
|
+
|
|
147
|
+
These editors all read MCP server config from JSON. Point `command` at
|
|
148
|
+
`python3 -m prgenius` and `args` at `mcp serve` (add `--repo-root` if the
|
|
149
|
+
auto-detected path doesn't match your checkout). No `env` keys, no auth.
|
|
150
|
+
|
|
151
|
+
**Claude Code** (`~/.claude/mcp.json` or project-local `.mcp.json`):
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"mcpServers": {
|
|
156
|
+
"pr-genius": {
|
|
157
|
+
"command": "python3",
|
|
158
|
+
"args": ["-m", "prgenius", "--repo-root", "/abs/path/to/big-repo-pr-knowledge", "mcp", "serve"]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Cursor** (`~/.cursor/mcp.json`):
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"mcpServers": {
|
|
169
|
+
"pr-genius": {
|
|
170
|
+
"command": "python3 -m prgenius",
|
|
171
|
+
"args": ["--repo-root", "/abs/path/to/big-repo-pr-knowledge", "mcp", "serve"]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Cline** (VS Code `cline_mcp_settings.json`):
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"mcpServers": {
|
|
182
|
+
"pr-genius": {
|
|
183
|
+
"command": "python3",
|
|
184
|
+
"args": ["-m", "prgenius", "--repo-root", "/abs/path/to/big-repo-pr-knowledge", "mcp", "serve"],
|
|
185
|
+
"disabled": false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Replace `/abs/path/to/big-repo-pr-knowledge` with the actual checkout.
|
|
192
|
+
Omit `--repo-root` only if you installed prgenius from this exact
|
|
193
|
+
checkout (then the default path resolves correctly).
|
|
194
|
+
|
|
195
|
+
## Schema we honor
|
|
196
|
+
|
|
197
|
+
- **rounds v0.5.0** — `action` enum + `delta` object + case-level `close_decision`
|
|
198
|
+
- **rounds v0.7.0** — adds optional `verified_at`, `evidence_urls`, `confidence`
|
|
199
|
+
to round + case level (BC over v0.5.0)
|
|
200
|
+
|
|
201
|
+
See [../ROUNDS_SCHEMA.md](../ROUNDS_SCHEMA.md) for the canonical schema.
|
|
202
|
+
|
|
203
|
+
## Why this exists
|
|
204
|
+
|
|
205
|
+
The repo is meant to be human-readable (`<org>-<repo>/index.md`) AND
|
|
206
|
+
machine-readable via this package. People get the narrative; agents get
|
|
207
|
+
structured tool calls. Both views share one source of truth.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT (same as parent repo).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
prgenius/__init__.py,sha256=chnEQhhj4qP0WgHgAwAPUqqdmHO_Uh5a7jRXY5i96xY,634
|
|
2
|
+
prgenius/__main__.py,sha256=x0rq9XROYyJoBBKrxmy8Ub5bS-963uM8_2hxOcLaHz0,58
|
|
3
|
+
prgenius/cli.py,sha256=fMkrOW1QjipMVy8ThifxZNPy83h5_WKD5xcPdP7U6EQ,5105
|
|
4
|
+
prgenius/mcp.py,sha256=d6UT58ht0ayp1DdaXHcLn6-UZtzP6lXhW5VlVeKhQHc,3568
|
|
5
|
+
prgenius/parser.py,sha256=WqLfWLvpdg-tK09rAEL6anG-6OeY-Ia32WbOMd_NpT0,7247
|
|
6
|
+
prgenius/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
prgenius_core-0.7.8.dist-info/METADATA,sha256=HSJ61NXzpa3nmpKi8b2UaSv2CbzPQt5UUNWH-CQuuqQ,6417
|
|
8
|
+
prgenius_core-0.7.8.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
prgenius_core-0.7.8.dist-info/entry_points.txt,sha256=fk1C2T45kVrWLwAqvDFSM_E3I8_mLIRHHIVzbQflMIQ,52
|
|
10
|
+
prgenius_core-0.7.8.dist-info/top_level.txt,sha256=5b3xARSoUbqq6gVLA8R0czvrRox9OQIJRuCffmnmG9Q,9
|
|
11
|
+
prgenius_core-0.7.8.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prgenius
|