Lumiin0us commited on
Commit ·
34b79cd
0
Parent(s):
GitSpec Initial Deployment
Browse files- .gitignore +9 -0
- Dockerfile +14 -0
- README.md +1 -0
- backend/__init__.py +0 -0
- backend/clone.py +31 -0
- backend/crawl.py +11 -0
- backend/extract.py +164 -0
- backend/historyExtractor.py +92 -0
- backend/historyIndexer.py +42 -0
- backend/indexer.py +30 -0
- backend/router.py +55 -0
- requirements.txt +6 -0
- streamlitUI.py +208 -0
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.DS_Store
|
| 5 |
+
/tmp/
|
| 6 |
+
venv/
|
| 7 |
+
.venv/
|
| 8 |
+
.jsonl
|
| 9 |
+
_repo/
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
EXPOSE 8501
|
| 13 |
+
|
| 14 |
+
CMD ["streamlit", "run", "streamlitUI.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GitSpec - AI Powered Git Inspection Tool
|
backend/__init__.py
ADDED
|
File without changes
|
backend/clone.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import stat
|
| 4 |
+
import tempfile
|
| 5 |
+
from git import Repo
|
| 6 |
+
|
| 7 |
+
def readOnlyHandler(func, path, execinfo):
|
| 8 |
+
os.chmod(path, stat.S_IWRITE)
|
| 9 |
+
func(path)
|
| 10 |
+
|
| 11 |
+
def cloneRepo(repository):
|
| 12 |
+
basePath = tempfile.gettempdir()
|
| 13 |
+
folderName = repository.rstrip('/').split('/')[-1]
|
| 14 |
+
destinationPath = os.path.join(basePath, folderName)
|
| 15 |
+
|
| 16 |
+
if os.path.exists(destinationPath):
|
| 17 |
+
shutil.rmtree(destinationPath, onerror=readOnlyHandler)
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
repo = Repo.clone_from(repository, destinationPath)
|
| 21 |
+
return destinationPath, repo
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print("Error: ", e)
|
| 24 |
+
return None, None
|
| 25 |
+
|
| 26 |
+
def cleanupRepo(path):
|
| 27 |
+
try:
|
| 28 |
+
if os.path.exists(path):
|
| 29 |
+
shutil.rmtree(path, onerror=readOnlyHandler)
|
| 30 |
+
except Exception as e:
|
| 31 |
+
print(f"Cleanup warning: {e}")
|
backend/crawl.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
def repoCrawler(folderName):
|
| 4 |
+
scriptDir = os.path.dirname(os.path.abspath(__file__))
|
| 5 |
+
pythonFiles = []
|
| 6 |
+
|
| 7 |
+
for (root,dirs,files) in os.walk(os.path.join(scriptDir, folderName),topdown=True):
|
| 8 |
+
for file in files:
|
| 9 |
+
if file.lower().endswith('.py'):
|
| 10 |
+
pythonFiles.append(os.path.join(root, file))
|
| 11 |
+
return pythonFiles
|
backend/extract.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import libcst as cst
|
| 2 |
+
import shortuuid
|
| 3 |
+
import json
|
| 4 |
+
import textwrap
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
class CallVisitor(cst.CSTVisitor):
|
| 8 |
+
def __init__(self):
|
| 9 |
+
super().__init__()
|
| 10 |
+
self.calls = set()
|
| 11 |
+
|
| 12 |
+
def visit_Call(self, node: cst.Call):
|
| 13 |
+
if isinstance(node.func, cst.Name):
|
| 14 |
+
self.calls.add(node.func.value)
|
| 15 |
+
elif isinstance(node.func, cst.Attribute):
|
| 16 |
+
if isinstance(node.func.attr, cst.Name):
|
| 17 |
+
self.calls.add(node.func.attr.value)
|
| 18 |
+
|
| 19 |
+
class MethodRemover(cst.CSTTransformer):
|
| 20 |
+
"""Removes top-level methods from a class body to create the 'Shell'."""
|
| 21 |
+
def leave_FunctionDef(self, originalNode, updatedNode):
|
| 22 |
+
return cst.RemoveFromParent()
|
| 23 |
+
|
| 24 |
+
class GlobalRemover(cst.CSTTransformer):
|
| 25 |
+
"""Isolates global variables by stripping functions, classes, and imports."""
|
| 26 |
+
def leave_FunctionDef(self, originalNode, updatedNode): return cst.RemoveFromParent()
|
| 27 |
+
def leave_ClassDef(self, originalNode, updatedNode): return cst.RemoveFromParent()
|
| 28 |
+
def leave_Import(self, originalNode, updatedNode): return cst.RemoveFromParent()
|
| 29 |
+
def leave_ImportFrom(self, originalNode, updatedNode): return cst.RemoveFromParent()
|
| 30 |
+
def leave_If(self, originalNode, updatedNode):
|
| 31 |
+
if isinstance(originalNode.test, cst.Comparison):
|
| 32 |
+
left = originalNode.test.left
|
| 33 |
+
if isinstance(left, cst.Name) and left.value == "__name__":
|
| 34 |
+
return cst.RemoveFromParent()
|
| 35 |
+
return updatedNode
|
| 36 |
+
|
| 37 |
+
# Utility Functions
|
| 38 |
+
def get_calls(node):
|
| 39 |
+
visitor = CallVisitor()
|
| 40 |
+
node.visit(visitor)
|
| 41 |
+
return sorted(list(visitor.calls))
|
| 42 |
+
|
| 43 |
+
def get_module_names(module_node):
|
| 44 |
+
names = set()
|
| 45 |
+
for node in module_node.body:
|
| 46 |
+
if isinstance(node, cst.SimpleStatementLine):
|
| 47 |
+
for s in node.body:
|
| 48 |
+
if isinstance(s, (cst.Import, cst.ImportFrom)):
|
| 49 |
+
if isinstance(s, cst.Import):
|
| 50 |
+
for alias in s.names:
|
| 51 |
+
fullName = module_node.code_for_node(alias.name)
|
| 52 |
+
names.add(fullName.split('.')[0])
|
| 53 |
+
elif isinstance(s, cst.ImportFrom):
|
| 54 |
+
if s.module:
|
| 55 |
+
fullName = module_node.code_for_node(s.module)
|
| 56 |
+
names.add(fullName.split('.')[0])
|
| 57 |
+
return sorted(list(names))
|
| 58 |
+
|
| 59 |
+
# Main Processor
|
| 60 |
+
def processPythonFile(files, folderName, repo, outputFile="code.jsonl"):
|
| 61 |
+
results = []
|
| 62 |
+
|
| 63 |
+
for file in files:
|
| 64 |
+
relPath = os.path.relpath(file, repo.working_tree_dir)
|
| 65 |
+
|
| 66 |
+
# Git Metadata
|
| 67 |
+
commits = list(repo.iter_commits(paths=relPath))
|
| 68 |
+
if commits:
|
| 69 |
+
lastCommit = commits[0]
|
| 70 |
+
firstCommit = commits[-1]
|
| 71 |
+
historyMetadata = {
|
| 72 |
+
"lastCommit": {
|
| 73 |
+
"hash": lastCommit.hexsha,
|
| 74 |
+
"msg": lastCommit.message.strip(),
|
| 75 |
+
"author": lastCommit.author.name,
|
| 76 |
+
"date": str(lastCommit.authored_datetime)
|
| 77 |
+
},
|
| 78 |
+
"firstCommit": {
|
| 79 |
+
"hash": firstCommit.hexsha,
|
| 80 |
+
"msg": firstCommit.message.strip(),
|
| 81 |
+
"author": firstCommit.author.name,
|
| 82 |
+
"date": str(firstCommit.authored_datetime)
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
else:
|
| 86 |
+
historyMetadata = {"lastCommit": None, "firstCommit": None}
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
with open(file, 'r', encoding='utf-8') as f:
|
| 90 |
+
sourceCode = f.read()
|
| 91 |
+
module = cst.parse_module(sourceCode)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"Could not read/parse file {file}: {e}")
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
external_deps = get_module_names(module)
|
| 97 |
+
globalVarsCode = module.visit(GlobalRemover()).code.strip()
|
| 98 |
+
filepath = folderName + file.split(folderName)[-1]
|
| 99 |
+
|
| 100 |
+
importLines = []
|
| 101 |
+
for node in module.body:
|
| 102 |
+
if isinstance(node, cst.SimpleStatementLine):
|
| 103 |
+
if any(isinstance(s, (cst.Import, cst.ImportFrom)) for s in node.body):
|
| 104 |
+
importLines.append(module.code_for_node(node).strip())
|
| 105 |
+
importedModulesCode = "\n".join(importLines)
|
| 106 |
+
|
| 107 |
+
for node in module.body:
|
| 108 |
+
# CASE 1: Top-Level Functions
|
| 109 |
+
if isinstance(node, cst.FunctionDef):
|
| 110 |
+
calls = get_calls(node)
|
| 111 |
+
header = (
|
| 112 |
+
f"# FILE: {filepath}\n"
|
| 113 |
+
f"# TYPE: Global Function\n"
|
| 114 |
+
f"# CALLS: {', '.join(calls)}\n"
|
| 115 |
+
f"# DEPS: {', '.join(external_deps)}\n"
|
| 116 |
+
f"# LAST COMMIT: {historyMetadata['lastCommit']['msg'] if historyMetadata['lastCommit'] else 'N/A'}"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
results.append({
|
| 120 |
+
'id': shortuuid.uuid(),
|
| 121 |
+
'name': node.name.value,
|
| 122 |
+
'filePath': filepath,
|
| 123 |
+
'calls': calls,
|
| 124 |
+
'external_deps': external_deps,
|
| 125 |
+
'history': historyMetadata,
|
| 126 |
+
'modules': importedModulesCode,
|
| 127 |
+
'globalVariables': globalVarsCode,
|
| 128 |
+
'parentClass': 'no parent',
|
| 129 |
+
'isAsync': node.asynchronous is not None,
|
| 130 |
+
'content': header + "\n\n" + module.code_for_node(node).strip(),
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
# CASE 2: Classes
|
| 134 |
+
elif isinstance(node, cst.ClassDef):
|
| 135 |
+
classShell = node.visit(MethodRemover())
|
| 136 |
+
classShellCode = module.code_for_node(classShell).strip()
|
| 137 |
+
|
| 138 |
+
for item in node.body.body:
|
| 139 |
+
if isinstance(item, cst.FunctionDef):
|
| 140 |
+
calls = get_calls(item)
|
| 141 |
+
header = (
|
| 142 |
+
f"# FILE: {filepath}\n"
|
| 143 |
+
f"# CLASS: {node.name.value}\n"
|
| 144 |
+
f"# TYPE: Method\n"
|
| 145 |
+
f"# CALLS: {', '.join(calls)}\n"
|
| 146 |
+
f"# DEPS: {', '.join(external_deps)}\n"
|
| 147 |
+
f"# LAST COMMIT: {historyMetadata['lastCommit']['msg'] if historyMetadata['lastCommit'] else 'N/A'}"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
results.append({
|
| 151 |
+
'id': shortuuid.uuid(),
|
| 152 |
+
'name': item.name.value,
|
| 153 |
+
'filePath': filepath,
|
| 154 |
+
'calls': calls,
|
| 155 |
+
'external_deps': external_deps,
|
| 156 |
+
'history': historyMetadata,
|
| 157 |
+
'modules': importedModulesCode,
|
| 158 |
+
'globalVariables': globalVarsCode,
|
| 159 |
+
'parentClass': classShellCode,
|
| 160 |
+
'isAsync': item.asynchronous is not None,
|
| 161 |
+
'content': header + "\n\n" + textwrap.dedent(module.code_for_node(item)).strip(),
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
return results
|
backend/historyExtractor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from git import Repo, GitCommandError, NULL_TREE
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
BOT_AUTHORS = ['github-actions[bot]', 'dependabot[bot]', 'pre-commit-ci[bot]', 'github-actions']
|
| 6 |
+
CODE_EXTENSIONS = ['.py']
|
| 7 |
+
MAX_FILES_PER_COMMIT = 20
|
| 8 |
+
MAX_COMMITS_TO_SCAN = 200
|
| 9 |
+
|
| 10 |
+
def splitDiffs(raw_diff):
|
| 11 |
+
if not raw_diff: return [], []
|
| 12 |
+
lines = raw_diff.split('\n')
|
| 13 |
+
added, removed = [], []
|
| 14 |
+
for line in lines:
|
| 15 |
+
if line.startswith('+++') or line.startswith('---') or line.startswith('\\'):
|
| 16 |
+
continue
|
| 17 |
+
if line.startswith('+'): added.append(line[1:])
|
| 18 |
+
elif line.startswith('-'): removed.append(line[1:])
|
| 19 |
+
return added, removed
|
| 20 |
+
|
| 21 |
+
def isCodeCommit(files_info):
|
| 22 |
+
return any(f['file'].endswith(ext) for ext in CODE_EXTENSIONS for f in files_info)
|
| 23 |
+
|
| 24 |
+
def extractHistory(repo, destPath, outputFile="commits_history.jsonl"):
|
| 25 |
+
try:
|
| 26 |
+
commits = list(repo.iter_commits(max_count=MAX_COMMITS_TO_SCAN))
|
| 27 |
+
except (GitCommandError, ValueError):
|
| 28 |
+
commits = []
|
| 29 |
+
|
| 30 |
+
indexedCount = 0
|
| 31 |
+
with open(outputFile, 'w', encoding='utf-8') as f:
|
| 32 |
+
for commit in commits:
|
| 33 |
+
if commit.author.name in BOT_AUTHORS:
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
parent = None
|
| 37 |
+
if commit.parents:
|
| 38 |
+
try:
|
| 39 |
+
p = commit.parents[0]
|
| 40 |
+
repo.git.cat_file('-e', p.hexsha)
|
| 41 |
+
parent = p
|
| 42 |
+
except GitCommandError:
|
| 43 |
+
parent = None
|
| 44 |
+
|
| 45 |
+
if parent:
|
| 46 |
+
diffs = parent.diff(commit, create_patch=True)
|
| 47 |
+
else:
|
| 48 |
+
diffs = commit.diff(NULL_TREE, create_patch=True, reverse=True)
|
| 49 |
+
|
| 50 |
+
files_info = []
|
| 51 |
+
for d in diffs:
|
| 52 |
+
filePath = d.b_path if d.b_path else d.a_path
|
| 53 |
+
rawPatch = d.diff.decode('utf-8', errors='replace') if d.diff else ""
|
| 54 |
+
addedLines, removedLines = splitDiffs(rawPatch)
|
| 55 |
+
|
| 56 |
+
status = d.change_type
|
| 57 |
+
if not status:
|
| 58 |
+
if addedLines and not removedLines: status = 'A'
|
| 59 |
+
elif removedLines and not addedLines: status = 'D'
|
| 60 |
+
else: status = 'M'
|
| 61 |
+
|
| 62 |
+
files_info.append({
|
| 63 |
+
'file': filePath,
|
| 64 |
+
'status': status,
|
| 65 |
+
'additions': addedLines[:20],
|
| 66 |
+
'removals': removedLines[:20]
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
if len(files_info) > MAX_FILES_PER_COMMIT:
|
| 70 |
+
continue
|
| 71 |
+
if not isCodeCommit(files_info):
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
filesTouched = ", ".join(fi['file'] for fi in files_info)
|
| 75 |
+
embedString = (f"{commit.summary} — files: {filesTouched} — "
|
| 76 |
+
f"author: {commit.author.name} — "
|
| 77 |
+
f"date: {commit.authored_datetime.date()}")
|
| 78 |
+
|
| 79 |
+
commit_info = {
|
| 80 |
+
'sha': commit.hexsha[:7],
|
| 81 |
+
'author': commit.author.name,
|
| 82 |
+
'summary': commit.summary,
|
| 83 |
+
'date': commit.authored_datetime.isoformat(),
|
| 84 |
+
'changes': files_info,
|
| 85 |
+
'embedText': embedString
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
f.write(json.dumps(commit_info) + '\n')
|
| 89 |
+
indexedCount += 1
|
| 90 |
+
|
| 91 |
+
print(f"Extracted {indexedCount} commits into {outputFile}")
|
| 92 |
+
return outputFile
|
backend/historyIndexer.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from qdrant_client import QdrantClient
|
| 3 |
+
from qdrant_client.models import Distance, VectorParams, PointStruct
|
| 4 |
+
from sentence_transformers import SentenceTransformer
|
| 5 |
+
|
| 6 |
+
def indexHistory(commitsFile, client, model):
|
| 7 |
+
"""
|
| 8 |
+
Reads a JSONL file of filtered commits and indexes them into Qdrant.
|
| 9 |
+
"""
|
| 10 |
+
collectionName = "historyIndex"
|
| 11 |
+
|
| 12 |
+
if client.collection_exists(collectionName):
|
| 13 |
+
client.delete_collection(collectionName)
|
| 14 |
+
|
| 15 |
+
client.create_collection(
|
| 16 |
+
collection_name=collectionName,
|
| 17 |
+
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
points = []
|
| 21 |
+
|
| 22 |
+
with open(commitsFile, 'r', encoding='utf-8') as f:
|
| 23 |
+
for i, line in enumerate(f):
|
| 24 |
+
commit = json.loads(line)
|
| 25 |
+
|
| 26 |
+
vector = model.encode(commit['embedText']).tolist()
|
| 27 |
+
|
| 28 |
+
points.append(
|
| 29 |
+
PointStruct(
|
| 30 |
+
id=i,
|
| 31 |
+
vector=vector,
|
| 32 |
+
payload=commit
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if points:
|
| 37 |
+
client.upsert(collection_name=collectionName, points=points)
|
| 38 |
+
print(f"Successfully indexed {len(points)} commits into {collectionName}")
|
| 39 |
+
else:
|
| 40 |
+
print("No commits found in file to index.")
|
| 41 |
+
|
| 42 |
+
return client, model
|
backend/indexer.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from qdrant_client import QdrantClient
|
| 2 |
+
from qdrant_client.models import Distance, VectorParams, PointStruct
|
| 3 |
+
from sentence_transformers import SentenceTransformer
|
| 4 |
+
|
| 5 |
+
def indexer(results):
|
| 6 |
+
# Qdrant IN RAM
|
| 7 |
+
client = QdrantClient(":memory:")
|
| 8 |
+
|
| 9 |
+
# Code-Strong model
|
| 10 |
+
model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 11 |
+
|
| 12 |
+
if client.collection_exists("tempCollection"):
|
| 13 |
+
client.delete_collection("tempCollection")
|
| 14 |
+
|
| 15 |
+
client.create_collection(
|
| 16 |
+
collection_name="tempCollection",
|
| 17 |
+
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
| 18 |
+
)
|
| 19 |
+
points = []
|
| 20 |
+
for i, entry in enumerate(results):
|
| 21 |
+
vector = model.encode(entry['content']).tolist()
|
| 22 |
+
points.append(
|
| 23 |
+
PointStruct(
|
| 24 |
+
id=i,
|
| 25 |
+
vector=vector,
|
| 26 |
+
payload=entry
|
| 27 |
+
)
|
| 28 |
+
)
|
| 29 |
+
client.upsert(collection_name="tempCollection", points=points)
|
| 30 |
+
return client, model
|
backend/router.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from groq import Groq
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
groqClient = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
| 9 |
+
|
| 10 |
+
def routeQuery(query: str) -> str:
|
| 11 |
+
"""
|
| 12 |
+
Classifies a user query into one of three routes:
|
| 13 |
+
CODE - search the code index (current structure, how things work)
|
| 14 |
+
HISTORY - search the history index (why things changed, who changed what)
|
| 15 |
+
BOTH - search both indexes and merge results
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
prompt = f"""You are a query router for a code intelligence tool called GitSpec.
|
| 19 |
+
GitSpec has two knowledge bases:
|
| 20 |
+
|
| 21 |
+
1. CODE INDEX — contains the current source code: functions, classes, methods, imports.
|
| 22 |
+
Use this for questions about how something works RIGHT NOW.
|
| 23 |
+
Examples: "how does authentication work?", "what does process_order do?", "what classes exist in the payment module?"
|
| 24 |
+
|
| 25 |
+
2. HISTORY INDEX — contains git commit history: what changed, when, who changed it, and why.
|
| 26 |
+
Use this for questions about change over time, blame, or reasoning.
|
| 27 |
+
Examples: "why did the auth module change?", "who introduced rate limiting?", "what changed last month?"
|
| 28 |
+
|
| 29 |
+
3. BOTH — use when the question needs current code understanding AND historical context.
|
| 30 |
+
Examples: "how does auth work and why was it redesigned?", "what does checkout do and when was it last changed?"
|
| 31 |
+
|
| 32 |
+
Classify this query into exactly one of: CODE, HISTORY, BOTH
|
| 33 |
+
|
| 34 |
+
Query: {query}
|
| 35 |
+
|
| 36 |
+
Respond with valid JSON only. No explanation. No markdown.
|
| 37 |
+
Format: {{"route": "CODE"}} or {{"route": "HISTORY"}} or {{"route": "BOTH"}}"""
|
| 38 |
+
|
| 39 |
+
response = groqClient.chat.completions.create(
|
| 40 |
+
model="llama-3.3-70b-versatile",
|
| 41 |
+
messages=[{"role": "user", "content": prompt}],
|
| 42 |
+
temperature=0.0,
|
| 43 |
+
max_tokens=20,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
raw = response.choices[0].message.content.strip()
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
result = json.loads(raw)
|
| 50 |
+
route = result.get("route", "BOTH").upper()
|
| 51 |
+
if route not in ["CODE", "HISTORY", "BOTH"]:
|
| 52 |
+
route = "BOTH"
|
| 53 |
+
return route
|
| 54 |
+
except json.JSONDecodeError:
|
| 55 |
+
return "BOTH"
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
qdrant-client
|
| 3 |
+
sentence-transformers
|
| 4 |
+
libcst
|
| 5 |
+
shortuuid
|
| 6 |
+
GitPython
|
streamlitUI.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from backend.clone import cloneRepo, cleanupRepo
|
| 3 |
+
from backend.crawl import repoCrawler
|
| 4 |
+
from backend.extract import processPythonFile
|
| 5 |
+
from backend.indexer import indexer
|
| 6 |
+
from backend.historyExtractor import extractHistory
|
| 7 |
+
from backend.historyIndexer import indexHistory
|
| 8 |
+
from backend.router import routeQuery
|
| 9 |
+
from groq import Groq
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
groqApiKey = os.getenv("GROQ_API_KEY")
|
| 15 |
+
|
| 16 |
+
st.set_page_config(page_title="GitSpec", layout="wide")
|
| 17 |
+
st.title("GitSpec")
|
| 18 |
+
|
| 19 |
+
if "client" not in st.session_state:
|
| 20 |
+
st.session_state.client = None
|
| 21 |
+
st.session_state.model = None
|
| 22 |
+
st.session_state.repo_name = ""
|
| 23 |
+
|
| 24 |
+
if "messages" not in st.session_state:
|
| 25 |
+
st.session_state.messages = []
|
| 26 |
+
|
| 27 |
+
# Sidebar Controls
|
| 28 |
+
with st.sidebar:
|
| 29 |
+
st.header("Settings")
|
| 30 |
+
if st.button("Clear Chat History"):
|
| 31 |
+
st.session_state.messages = []
|
| 32 |
+
st.rerun()
|
| 33 |
+
st.divider()
|
| 34 |
+
contextLimit = st.slider("Context Depth (Snippets)", 3, 10, 6)
|
| 35 |
+
|
| 36 |
+
repoUrl = st.text_input("Enter GitHub Repository URL:")
|
| 37 |
+
|
| 38 |
+
if st.button("Analyze Repository"):
|
| 39 |
+
if repoUrl:
|
| 40 |
+
st.session_state.repo_name = repoUrl.split('/')[-1].replace('.git', '')
|
| 41 |
+
progressBar = st.progress(0)
|
| 42 |
+
statusText = st.empty()
|
| 43 |
+
|
| 44 |
+
statusText.text("Cloning repository...")
|
| 45 |
+
progressBar.progress(10)
|
| 46 |
+
destPath, repo = cloneRepo(repoUrl)
|
| 47 |
+
|
| 48 |
+
if destPath and repo:
|
| 49 |
+
progressBar.progress(25)
|
| 50 |
+
statusText.text("Scanning files...")
|
| 51 |
+
files = repoCrawler(destPath)
|
| 52 |
+
|
| 53 |
+
progressBar.progress(40)
|
| 54 |
+
statusText.text("Extracting code structures...")
|
| 55 |
+
results = processPythonFile(files, destPath, repo)
|
| 56 |
+
|
| 57 |
+
progressBar.progress(55)
|
| 58 |
+
statusText.text("Indexing code...")
|
| 59 |
+
client, model = indexer(results)
|
| 60 |
+
|
| 61 |
+
progressBar.progress(70)
|
| 62 |
+
statusText.text("Extracting commit history...")
|
| 63 |
+
commitsFile = extractHistory(repo, destPath)
|
| 64 |
+
|
| 65 |
+
progressBar.progress(85)
|
| 66 |
+
statusText.text("Indexing history...")
|
| 67 |
+
client, model = indexHistory(commitsFile, client, model)
|
| 68 |
+
|
| 69 |
+
st.session_state.client = client
|
| 70 |
+
st.session_state.model = model
|
| 71 |
+
st.session_state.messages = []
|
| 72 |
+
|
| 73 |
+
progressBar.progress(100)
|
| 74 |
+
statusText.text("Cleaning up...")
|
| 75 |
+
cleanupRepo(destPath)
|
| 76 |
+
|
| 77 |
+
statusText.empty()
|
| 78 |
+
progressBar.empty()
|
| 79 |
+
st.success(f"'{st.session_state.repo_name}' Repository indexed successfully")
|
| 80 |
+
else:
|
| 81 |
+
statusText.empty()
|
| 82 |
+
progressBar.empty()
|
| 83 |
+
st.error("Failed to clone repository.")
|
| 84 |
+
else:
|
| 85 |
+
st.warning("Please enter a URL.")
|
| 86 |
+
|
| 87 |
+
if st.session_state.client:
|
| 88 |
+
st.divider()
|
| 89 |
+
|
| 90 |
+
for message in st.session_state.messages:
|
| 91 |
+
with st.chat_message(message["role"]):
|
| 92 |
+
st.markdown(message["content"])
|
| 93 |
+
|
| 94 |
+
if query := st.chat_input("Ask anything about the codebase or its history..."):
|
| 95 |
+
|
| 96 |
+
with st.chat_message("user"):
|
| 97 |
+
st.markdown(query)
|
| 98 |
+
st.session_state.messages.append({"role": "user", "content": query})
|
| 99 |
+
|
| 100 |
+
groqClient = Groq(api_key=groqApiKey)
|
| 101 |
+
|
| 102 |
+
with st.spinner("Thinking..."):
|
| 103 |
+
|
| 104 |
+
# Route the query
|
| 105 |
+
route = routeQuery(query)
|
| 106 |
+
|
| 107 |
+
queryVector = st.session_state.model.encode(query).tolist()
|
| 108 |
+
contextBlocks = []
|
| 109 |
+
searchResults = []
|
| 110 |
+
|
| 111 |
+
# Search the right index(es)
|
| 112 |
+
if route in ["CODE", "BOTH"]:
|
| 113 |
+
codeResponse = st.session_state.client.query_points(
|
| 114 |
+
collection_name="tempCollection",
|
| 115 |
+
query=queryVector,
|
| 116 |
+
limit=contextLimit
|
| 117 |
+
)
|
| 118 |
+
for res in codeResponse.points:
|
| 119 |
+
p = res.payload
|
| 120 |
+
block = (
|
| 121 |
+
f"[CODE] FILE: {p.get('filePath')}\n"
|
| 122 |
+
f"METADATA: Parent={p.get('parentClass')}, Imports={p.get('modules')}\n"
|
| 123 |
+
f"LAST COMMIT: {p.get('history', {}).get('lastCommit', {}).get('msg')}\n"
|
| 124 |
+
f"CODE:\n{p.get('content')}"
|
| 125 |
+
)
|
| 126 |
+
contextBlocks.append(block)
|
| 127 |
+
searchResults.append(("code", res))
|
| 128 |
+
|
| 129 |
+
if route in ["HISTORY", "BOTH"]:
|
| 130 |
+
historyResponse = st.session_state.client.query_points(
|
| 131 |
+
collection_name="historyIndex",
|
| 132 |
+
query=queryVector,
|
| 133 |
+
limit=contextLimit
|
| 134 |
+
)
|
| 135 |
+
for res in historyResponse.points:
|
| 136 |
+
p = res.payload
|
| 137 |
+
block = (
|
| 138 |
+
f"[HISTORY] COMMIT: {p.get('sha')} by {p.get('author')} on {p.get('date', '')[:10]}\n"
|
| 139 |
+
f"SUMMARY: {p.get('summary')}\n"
|
| 140 |
+
f"FILES: {', '.join(f['file'] for f in p.get('changes', []))}\n"
|
| 141 |
+
f"CHANGES: {p.get('embedText')}"
|
| 142 |
+
)
|
| 143 |
+
contextBlocks.append(block)
|
| 144 |
+
searchResults.append(("history", res))
|
| 145 |
+
|
| 146 |
+
if not contextBlocks:
|
| 147 |
+
fullResponse = "I couldn't find relevant information. Try rephrasing your question."
|
| 148 |
+
else:
|
| 149 |
+
formattedContext = "\n---\n".join(contextBlocks)
|
| 150 |
+
|
| 151 |
+
systemPrompts = (
|
| 152 |
+
"You are a Senior Software Architect and code historian for GitSpec. "
|
| 153 |
+
"You have access to two knowledge sources: current source code (CODE) and git commit history (HISTORY). "
|
| 154 |
+
"Use whichever is relevant to answer the question accurately."
|
| 155 |
+
"\n\nSTRICT FORMATTING RULES:"
|
| 156 |
+
"\n- Start immediately with the answer."
|
| 157 |
+
"\n- Use H3 headers (###) for distinct sections."
|
| 158 |
+
"\n- For history answers, always cite the commit SHA and author."
|
| 159 |
+
"\n- For code answers, reference the file and function name."
|
| 160 |
+
"\n- Do NOT include absolute local file paths."
|
| 161 |
+
"\n- Keep the tone professional and concise."
|
| 162 |
+
"\n- Be concise. No 'In conclusion' or summary sections — end when the answer is complete."
|
| 163 |
+
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
llm_messages = [
|
| 167 |
+
{"role": "system", "content": systemPrompts},
|
| 168 |
+
*st.session_state.messages[-5:],
|
| 169 |
+
{"role": "user", "content": f"Context:\n{formattedContext}\n\nQuestion: {query}"}
|
| 170 |
+
]
|
| 171 |
+
try:
|
| 172 |
+
llmResponse = groqClient.chat.completions.create(
|
| 173 |
+
model="llama-3.3-70b-versatile",
|
| 174 |
+
messages=llm_messages,
|
| 175 |
+
temperature=0.1
|
| 176 |
+
)
|
| 177 |
+
fullResponse = llmResponse.choices[0].message.content
|
| 178 |
+
except Exception as e:
|
| 179 |
+
if "rate_limit_exceeded" in str(e).lower() or "413" in str(e):
|
| 180 |
+
fullResponse = (
|
| 181 |
+
"**Rate Limit Reached:** The context for this repository is quite large for the free tier. "
|
| 182 |
+
"I've tried to answer, but Groq is busy. Please try: \n"
|
| 183 |
+
"1. Reducing the **Context Depth** slider in the sidebar.\n"
|
| 184 |
+
"2. Asking a more specific question.\n"
|
| 185 |
+
"3. Waiting 60 seconds and trying again."
|
| 186 |
+
)
|
| 187 |
+
else:
|
| 188 |
+
fullResponse = f"An unexpected error occurred: {str(e)}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# Display answer
|
| 192 |
+
with st.chat_message("assistant"):
|
| 193 |
+
st.markdown(fullResponse)
|
| 194 |
+
st.caption(f"Route: `{route}`")
|
| 195 |
+
|
| 196 |
+
if searchResults:
|
| 197 |
+
with st.expander("Explore Reference Sources"):
|
| 198 |
+
for sourceType, res in searchResults:
|
| 199 |
+
p = res.payload
|
| 200 |
+
if sourceType == "code":
|
| 201 |
+
st.caption(f"[CODE] {p.get('filePath')}")
|
| 202 |
+
st.code(p.get('content'), language='python')
|
| 203 |
+
else:
|
| 204 |
+
st.caption(f"[HISTORY] {p.get('sha')} — {p.get('summary')}")
|
| 205 |
+
for change in p.get('changes', []):
|
| 206 |
+
st.caption(f"File: {change['file']} ({change['status']})")
|
| 207 |
+
|
| 208 |
+
st.session_state.messages.append({"role": "assistant", "content": fullResponse})
|