Skip to content

Commit 6fea5da

Browse files
authored
Merge pull request #4 from kpj2006/patch-1
feat: Implement semantic clustering and analysis for PRs
2 parents 6bfb0c9 + a3d765f commit 6fea5da

8 files changed

Lines changed: 1580 additions & 0 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,9 @@ TSWLatexianTemp*
324324
# option is specified. Footnotes are the stored in a file with suffix Notes.bib.
325325
# Uncomment the next line to have this generated file ignored.
326326
#*Notes.bib
327+
328+
__pycache__/
329+
venv/
330+
*.pyc
331+
conflicts_tree.html
332+
isolated_prs.html

context.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# MiniChain Context
2+
3+
MiniChain is a minimal, educational implementation of a Proof-of-Work (PoW) blockchain written in Python. It includes a P2P network layer, a mempool for transaction management, cryptographic transaction signing, state management with accounts, and a minimal sandboxed smart contract execution environment.
4+
5+
## Architecture & Components
6+
7+
The `minichain` package is composed of the following files, each responsible for a specific part of the blockchain:
8+
9+
- **`__init__.py`**: Exports the primary classes and functions for the package, acting as the public API.
10+
- **`block.py`**: Defines the `Block` class, which handles block structure, deterministic timestamps, Merkle root calculation for transactions, and serialization for mining and hashing.
11+
- **`chain.py`**: Contains the `Blockchain` class which manages the chain of blocks, handles adding new blocks after validating their hashes, and executes transactions against a temporary state to ensure atomicity.
12+
- **`contract.py`**: Implements `ContractMachine`, a minimal, sandboxed Python-based smart contract execution environment. It uses `multiprocessing`, resource limits, and AST validation to restrict available built-ins and prevent malicious code execution.
13+
- **`mempool.py`**: Defines the `Mempool` class, which holds pending valid transactions before they are mined into a block. It handles duplicate prevention, max size limits, and deterministic sorting for block inclusion.
14+
- **`p2p.py`**: Implements a lightweight TCP-based peer-to-peer network (`P2PNetwork`) using `asyncio`. It handles connecting to peers, listening for incoming connections, broadcasting blocks and transactions, and validating incoming JSON messages.
15+
- **`persistence.py`**: Provides utility functions to `save` and `load` the blockchain and state to a local JSON file (`data.json`) atomically. It uses fsync and temp files to prevent corruption and verifies chain integrity on load.
16+
- **`pow.py`**: Contains the Proof-of-Work mining logic (`mine_block` and `calculate_hash`), enforcing difficulty targets, nonces, limits, and optional timeout conditions.
17+
- **`serialization.py`**: Provides helper functions (`canonical_json_dumps`, `canonical_json_bytes`, `canonical_json_hash`) for deterministic JSON serialization, crucial for consistent signature verification and hashing across environments.
18+
- **`state.py`**: Defines the `State` class, which manages account balances, nonces, and contract storage. It evaluates and applies transactions via distinct branches: regular transfers, contract deployments, and contract calls.
19+
- **`transaction.py`**: Defines the `Transaction` class, handling transaction structure, digital signatures using `nacl` (Ed25519), verification, deterministic transaction IDs, and serialization.
20+
- **`validators.py`**: Provides simple validation utilities, such as `is_valid_receiver`, ensuring addresses correspond to standard hex lengths (40 or 64 characters).

dashboard.py

Lines changed: 640 additions & 0 deletions
Large diffs are not rendered by default.

github.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
github.py — all GitHub CLI interactions
3+
"""
4+
5+
import subprocess, json, re
6+
7+
REPO = "StabilityNexus/MiniChain"
8+
9+
def gh(endpoint):
10+
result = subprocess.run(
11+
["gh", "api", f"https://api.github.com/{endpoint}"],
12+
capture_output=True, encoding="utf-8", errors="replace"
13+
)
14+
if result.returncode != 0:
15+
return []
16+
try:
17+
return json.loads(result.stdout)
18+
except Exception:
19+
return []
20+
21+
def gh_paginate(endpoint):
22+
result = subprocess.run(
23+
["gh", "api", "--paginate", f"https://api.github.com/{endpoint}"],
24+
capture_output=True, encoding="utf-8", errors="replace"
25+
)
26+
if result.returncode != 0:
27+
return []
28+
text = result.stdout.strip()
29+
if not text:
30+
return []
31+
try:
32+
return json.loads(text)
33+
except json.JSONDecodeError:
34+
try:
35+
text = "[" + text.replace("][", ",") + "]"
36+
flat = json.loads(text)
37+
return [i for sub in flat for i in sub] if flat and isinstance(flat[0], list) else flat
38+
except Exception:
39+
return []
40+
41+
def fetch_prs():
42+
# TEST: last 10 closed. For production use gh_paginate below.
43+
prs = gh(f"repos/{REPO}/pulls?state=closed&per_page=10&sort=updated&direction=desc")
44+
return prs if isinstance(prs, list) else []
45+
# PRODUCTION:
46+
# return gh_paginate(f"repos/{REPO}/pulls?state=open&per_page=100")
47+
48+
def fetch_pr_files(n):
49+
files = gh(f"repos/{REPO}/pulls/{n}/files?per_page=100")
50+
return [f["filename"] for f in files] if isinstance(files, list) else []
51+
52+
def fetch_coderabbit_sections(n):
53+
"""
54+
Fetch ONLY the Walkthrough and Changes sections from CodeRabbit.
55+
Everything else (sequence diagrams, poems, tips) is ignored.
56+
"""
57+
raw = None
58+
59+
# Check issue comments first
60+
comments = gh(f"repos/{REPO}/issues/{n}/comments?per_page=50")
61+
if isinstance(comments, list):
62+
for c in comments:
63+
if "coderabbit" in c.get("user", {}).get("login", "").lower() and c.get("body"):
64+
raw = c["body"]
65+
break
66+
67+
# Fallback: PR reviews
68+
if not raw:
69+
reviews = gh(f"repos/{REPO}/pulls/{n}/reviews?per_page=50")
70+
if isinstance(reviews, list):
71+
for r in reviews:
72+
if "coderabbit" in r.get("user", {}).get("login", "").lower() and r.get("body"):
73+
raw = r["body"]
74+
break
75+
76+
if not raw:
77+
return None
78+
79+
return extract_walkthrough_and_changes(raw)
80+
81+
def extract_walkthrough_and_changes(text):
82+
"""
83+
Pull only the Walkthrough and Changes table from a CodeRabbit comment.
84+
CodeRabbit structure:
85+
## Walkthrough
86+
<text>
87+
## Changes
88+
| File | Summary |
89+
...
90+
## Sequence Diagram(s) ← stop here
91+
"""
92+
result = {}
93+
94+
# Extract Walkthrough section
95+
wt_match = re.search(
96+
r"##\s*Walkthrough\s*\n(.*?)(?=\n##\s|\Z)",
97+
text, re.DOTALL | re.IGNORECASE
98+
)
99+
if wt_match:
100+
result["walkthrough"] = wt_match.group(1).strip()
101+
102+
# Extract Changes section (table)
103+
ch_match = re.search(
104+
r"##\s*Changes\s*\n(.*?)(?=\n##\s|\Z)",
105+
text, re.DOTALL | re.IGNORECASE
106+
)
107+
if ch_match:
108+
result["changes"] = ch_match.group(1).strip()
109+
110+
if not result:
111+
# Fallback: return first 800 chars if structure not found
112+
result["walkthrough"] = text[:800]
113+
result["changes"] = ""
114+
115+
return result
116+
117+
def extract_linked_issue(body):
118+
if not body:
119+
return None
120+
m = re.search(r"(?:fixes|closes|resolves)\s+#(\d+)", body, re.IGNORECASE)
121+
return int(m.group(1)) if m else None
122+
123+
def check_gh_auth():
124+
result = subprocess.run(
125+
["gh", "auth", "status"],
126+
capture_output=True, encoding="utf-8", errors="replace"
127+
)
128+
return result.returncode == 0

grouping.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
grouping.py — semantic clustering via sentence-transformers
3+
No LLM for grouping. Embeddings + community_detection instead.
4+
Ollama only called AFTER groups are found, to explain WHY they conflict.
5+
"""
6+
7+
from sentence_transformers import SentenceTransformer, util
8+
from ollama import analyse_group, analyse_single_pr
9+
10+
MODEL_NAME = "all-MiniLM-L6-v2" # ~90MB, downloads once, runs on CPU
11+
THRESHOLD = 0.55 # similarity threshold — tune if too many/few matches
12+
MIN_SIZE = 2 # min PRs to form a conflict group
13+
14+
def resolve_groups(pr_data, repo_context=""):
15+
"""
16+
1. Embed each PR walkthrough using sentence-transformers
17+
2. Cluster by semantic similarity (community_detection)
18+
3. Groups with 2+ PRs = conflict → deep Ollama analysis
19+
4. Singletons = isolated → single Ollama analysis
20+
"""
21+
print("\n Loading embedding model (first run downloads ~90MB)...")
22+
model = SentenceTransformer(MODEL_NAME)
23+
24+
# Build text to embed: title + walkthrough for each PR
25+
texts = []
26+
for pr in pr_data:
27+
cr = pr.get("coderabbit") or {}
28+
wt = cr.get("walkthrough", "")[:600]
29+
ch = cr.get("changes", "")[:300]
30+
texts.append(f"{pr['title']}\n{wt}\n{ch}")
31+
32+
print(f" Embedding {len(pr_data)} PRs...")
33+
embeddings = model.encode(texts, convert_to_tensor=True, show_progress_bar=False)
34+
35+
print(f" Clustering (threshold={THRESHOLD})...")
36+
clusters = util.community_detection(
37+
embeddings,
38+
min_community_size=MIN_SIZE,
39+
threshold=THRESHOLD
40+
)
41+
42+
# clusters = list of lists of indices into pr_data
43+
# PRs not in any cluster are isolated
44+
clustered_indices = set(idx for cluster in clusters for idx in cluster)
45+
isolated_indices = [i for i in range(len(pr_data)) if i not in clustered_indices]
46+
47+
print(f"\n Found {len(clusters)} conflict group(s), {len(isolated_indices)} isolated PR(s)")
48+
49+
# Show similarity scores for transparency
50+
similarity_matrix = util.cos_sim(embeddings, embeddings)
51+
for ci, cluster in enumerate(clusters):
52+
pr_nums = [pr_data[i]["number"] for i in cluster]
53+
scores = []
54+
for x in range(len(cluster)):
55+
for y in range(x+1, len(cluster)):
56+
s = float(similarity_matrix[cluster[x]][cluster[y]])
57+
scores.append(f"#{pr_nums[x]}&#{pr_nums[y]}={s:.2f}")
58+
print(f" Group {ci+1}: PRs {pr_nums} — similarity: {', '.join(scores)}")
59+
60+
conflict_groups = []
61+
isolated_prs = []
62+
63+
# ── Conflict groups ───────────────────────────────────────────────────────
64+
for cluster in clusters:
65+
prs = [pr_data[i] for i in cluster]
66+
pr_nums = [p["number"] for p in prs]
67+
68+
# Build a pseudo group_meta for analyse_group
69+
group_meta = {
70+
"problem": f"PRs {pr_nums} grouped by semantic similarity",
71+
"problem_category": "unknown",
72+
}
73+
74+
print(f"\n Conflict group: PRs {pr_nums}")
75+
print(f" Running deep group analysis with Ollama...")
76+
group_analysis = analyse_group(group_meta, prs, repo_context)
77+
78+
if group_analysis and "pr_analyses" in group_analysis:
79+
pr_by_num = {p["number"]: p for p in prs}
80+
for pa in group_analysis["pr_analyses"]:
81+
n = pa.get("pr_number")
82+
if n in pr_by_num:
83+
pr_by_num[n]["analysis"] = pa
84+
85+
# Use avg similarity to guess duplicate vs conflict (NLI will improve this later)
86+
avg_sim = float(similarity_matrix[cluster[0]][cluster[1]]) if len(cluster) > 1 else 0
87+
gtype = "duplicate" if avg_sim > 0.75 else "conflict"
88+
conflict_groups.append({
89+
"category": group_analysis.get("problem_category", "unknown") if group_analysis else "unknown",
90+
"problem": group_analysis.get("shared_problem", f"PRs {pr_nums}") if group_analysis else f"PRs {pr_nums}",
91+
"prs": prs,
92+
"analysis": group_analysis or {},
93+
"group_type": gtype,
94+
})
95+
96+
# ── Isolated PRs ──────────────────────────────────────────────────────────
97+
for i in isolated_indices:
98+
pr = pr_data[i]
99+
print(f"\n Isolated PR #{pr['number']} — analysing...")
100+
pr["analysis"] = analyse_single_pr(pr, repo_context)
101+
isolated_prs.append(pr)
102+
103+
return conflict_groups, isolated_prs

main.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
main.py — entry point
3+
4+
Flow:
5+
1. Load context.md (repo context — what MiniChain is, what's already built)
6+
2. Fetch all PRs + extract CodeRabbit walkthrough + changes only
7+
3. One combined Ollama call → groups PRs by problem
8+
4. Deep Ollama analysis per conflict group (all PRs in group together)
9+
5. Single Ollama analysis per isolated PR
10+
6. Render two HTML files and open in browser
11+
12+
Run: python main.py
13+
Requires: gh (authenticated), ollama running on localhost:11434
14+
Optional: context.md in same folder (drop it in when ready)
15+
"""
16+
17+
import os, time, webbrowser
18+
from github import fetch_prs, fetch_pr_files, fetch_coderabbit_sections, check_gh_auth, REPO
19+
from ollama import check_ollama
20+
from grouping import resolve_groups
21+
from render import build_conflict_html, build_isolated_html
22+
23+
OUT_DIR = os.path.dirname(os.path.abspath(__file__))
24+
CONTEXT_FILE = os.path.join(OUT_DIR, "context.md")
25+
26+
def load_context():
27+
if os.path.exists(CONTEXT_FILE):
28+
with open(CONTEXT_FILE, "r", encoding="utf-8") as f:
29+
content = f.read().strip()
30+
print(f" Loaded context.md ({len(content)} chars)")
31+
return content
32+
print(" No context.md found — running without repo context")
33+
print(" (Drop context.md in the same folder to enable it)")
34+
return ""
35+
36+
def main():
37+
if not check_gh_auth():
38+
print("ERROR: gh not authenticated. Run: gh auth login")
39+
return
40+
41+
if not check_ollama():
42+
print("ERROR: Ollama not reachable at localhost:11434")
43+
return
44+
45+
print(f"\nLoading repo context...")
46+
repo_context = load_context()
47+
48+
print(f"\nFetching PRs for {REPO}...")
49+
raw_prs = fetch_prs()
50+
if not raw_prs:
51+
print("No PRs found.")
52+
return
53+
54+
print(f"Found {len(raw_prs)} PRs. Fetching walkthroughs...\n")
55+
pr_data = []
56+
for raw in raw_prs:
57+
num = raw["number"]
58+
author = raw["user"]["login"]
59+
print(f" PR #{num}{raw['title'][:55]}")
60+
pr_data.append({
61+
"number": num,
62+
"title": raw["title"],
63+
"author": author,
64+
"created_at": raw["created_at"],
65+
"body": raw.get("body", "") or "",
66+
"files": fetch_pr_files(num),
67+
"coderabbit": fetch_coderabbit_sections(num),
68+
})
69+
70+
# ── Step 2: Semantic clustering + deep analysis ──────────────────────────
71+
print(f"\nClustering {len(pr_data)} PRs by semantic similarity...")
72+
conflict_groups, isolated = resolve_groups(pr_data, repo_context)
73+
74+
print(f"\n Conflict groups : {len(conflict_groups)}")
75+
print(f" Isolated PRs : {len(isolated)}")
76+
77+
# ── Render ────────────────────────────────────────────────────────────────
78+
tree_path = os.path.join(OUT_DIR, "conflicts_tree.html")
79+
iso_path = os.path.join(OUT_DIR, "isolated_prs.html")
80+
81+
with open(tree_path, "w", encoding="utf-8") as f:
82+
f.write(build_conflict_html(conflict_groups))
83+
with open(iso_path, "w", encoding="utf-8") as f:
84+
f.write(build_isolated_html(isolated))
85+
86+
print(f"\nOpening conflict tree -> {tree_path}")
87+
webbrowser.open(f"file:///{tree_path}")
88+
time.sleep(1)
89+
print(f"Opening isolated PRs -> {iso_path}")
90+
webbrowser.open(f"file:///{iso_path}")
91+
print("\nDone.")
92+
93+
if __name__ == "__main__":
94+
main()

0 commit comments

Comments
 (0)