ace-git-copilot 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.
- ace/__init__.py +1 -0
- ace/ai/changelog_generator.py +70 -0
- ace/ai/code_reviewer.py +81 -0
- ace/ai/commit_generator.py +73 -0
- ace/ai/conflict_resolver.py +115 -0
- ace/ai/gitignore_generator.py +45 -0
- ace/ai/history_analyzer.py +188 -0
- ace/ai/intent_parser.py +65 -0
- ace/ai/llm_factory.py +116 -0
- ace/ai/pr_drafter.py +61 -0
- ace/ai/prompts/changelog.py +30 -0
- ace/ai/prompts/commit.py +74 -0
- ace/ai/prompts/conflict.py +40 -0
- ace/ai/prompts/explain.py +20 -0
- ace/ai/prompts/ignore.py +19 -0
- ace/ai/prompts/intent.py +111 -0
- ace/ai/prompts/pr.py +29 -0
- ace/ai/prompts/review.py +52 -0
- ace/ai/prompts/search.py +18 -0
- ace/ai/prompts/undo.py +47 -0
- ace/cli.py +1191 -0
- ace/core/config.py +129 -0
- ace/core/context.py +172 -0
- ace/core/git_ops.py +193 -0
- ace/core/safety.py +110 -0
- ace/ui/banner.py +64 -0
- ace/ui/dashboard.py +200 -0
- ace/ui/display.py +133 -0
- ace/ui/prompts.py +69 -0
- ace/ui/themes.py +22 -0
- ace/utils/conflict_parser.py +62 -0
- ace/utils/diff_parser.py +94 -0
- ace/utils/json_utils.py +35 -0
- ace_git_copilot-0.1.0.dist-info/METADATA +113 -0
- ace_git_copilot-0.1.0.dist-info/RECORD +37 -0
- ace_git_copilot-0.1.0.dist-info/WHEEL +4 -0
- ace_git_copilot-0.1.0.dist-info/entry_points.txt +2 -0
ace/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
3
|
+
from ace.core.git_ops import GitOps
|
|
4
|
+
from ace.ai.llm_factory import get_llm
|
|
5
|
+
from ace.ai.prompts.changelog import CHANGELOG_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
6
|
+
|
|
7
|
+
class ChangelogGeneratorError(Exception):
|
|
8
|
+
"""Raised when changelog generation fails."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class ChangelogGenerator:
|
|
12
|
+
def __init__(self, git_ops: GitOps):
|
|
13
|
+
self.git_ops = git_ops
|
|
14
|
+
|
|
15
|
+
def get_commits_in_range(self, from_ref: Optional[str] = None, to_ref: Optional[str] = None) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Retrieve formatted commit log between from_ref and to_ref.
|
|
18
|
+
If from_ref is None, attempts to find the latest tag.
|
|
19
|
+
If no latest tag exists, falls back to the last 30 commits.
|
|
20
|
+
"""
|
|
21
|
+
to_revision = to_ref or "HEAD"
|
|
22
|
+
from_revision = from_ref
|
|
23
|
+
|
|
24
|
+
# If from_ref is not provided, try to find the latest tag
|
|
25
|
+
if not from_revision:
|
|
26
|
+
try:
|
|
27
|
+
# git describe --tags --abbrev=0 to get latest tag
|
|
28
|
+
latest_tag = self.git_ops.execute("describe --tags --abbrev=0").strip()
|
|
29
|
+
from_revision = latest_tag
|
|
30
|
+
except Exception:
|
|
31
|
+
# No tags found
|
|
32
|
+
from_revision = None
|
|
33
|
+
|
|
34
|
+
log_args = ["log"]
|
|
35
|
+
if from_revision:
|
|
36
|
+
log_args.append(f"{from_revision}..{to_revision}")
|
|
37
|
+
else:
|
|
38
|
+
# Fallback to last 30 commits
|
|
39
|
+
log_args.extend(["-n", "30"])
|
|
40
|
+
|
|
41
|
+
# Format: hash|date|author|subject\nbody
|
|
42
|
+
log_args.append('--format=%H|%ad|%an|%s%n%b')
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
cmd = " ".join(log_args)
|
|
46
|
+
return self.git_ops.execute(cmd).strip()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise ChangelogGeneratorError(f"Failed to fetch commit log: {e}")
|
|
49
|
+
|
|
50
|
+
def generate_changelog(self, from_ref: Optional[str] = None, to_ref: Optional[str] = None, offline: bool = False) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Retrieve commits and generate a release changelog in Markdown.
|
|
53
|
+
"""
|
|
54
|
+
commit_log = self.get_commits_in_range(from_ref, to_ref)
|
|
55
|
+
if not commit_log.strip():
|
|
56
|
+
return "No commits found in the specified range."
|
|
57
|
+
|
|
58
|
+
llm = get_llm(offline_override=offline)
|
|
59
|
+
|
|
60
|
+
user_prompt = USER_PROMPT_TEMPLATE.format(
|
|
61
|
+
commit_log=commit_log[:20000] # Cap log length to respect context window
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
messages = [
|
|
65
|
+
SystemMessage(content=CHANGELOG_SYSTEM_PROMPT),
|
|
66
|
+
HumanMessage(content=user_prompt)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
response = llm.invoke(messages)
|
|
70
|
+
return response.content.strip()
|
ace/ai/code_reviewer.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Dict, Any, List, Tuple
|
|
2
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
3
|
+
from ace.core.git_ops import GitOps
|
|
4
|
+
from ace.utils.diff_parser import split_diff_by_file, trim_diff
|
|
5
|
+
from ace.ai.llm_factory import get_llm
|
|
6
|
+
from ace.ai.prompts.review import REVIEW_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
7
|
+
from ace.utils.json_utils import extract_json
|
|
8
|
+
|
|
9
|
+
class CodeReviewerError(Exception):
|
|
10
|
+
"""Raised when code review processing fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class CodeReviewer:
|
|
14
|
+
def __init__(self, git_ops: GitOps):
|
|
15
|
+
self.git_ops = git_ops
|
|
16
|
+
|
|
17
|
+
def review_diff(self, diff_text: str, offline: bool = False) -> Tuple[List[Dict[str, Any]], float]:
|
|
18
|
+
"""
|
|
19
|
+
Perform a code review on a raw diff string.
|
|
20
|
+
|
|
21
|
+
Returns a tuple of (findings, overall_score).
|
|
22
|
+
"""
|
|
23
|
+
if not diff_text.strip():
|
|
24
|
+
return [], 10.0
|
|
25
|
+
|
|
26
|
+
# Split diff by file
|
|
27
|
+
file_diffs = split_diff_by_file(diff_text)
|
|
28
|
+
if not file_diffs:
|
|
29
|
+
return [], 10.0
|
|
30
|
+
|
|
31
|
+
all_findings = []
|
|
32
|
+
scores = []
|
|
33
|
+
llm = get_llm(offline_override=offline)
|
|
34
|
+
|
|
35
|
+
for filename, diff_content in file_diffs.items():
|
|
36
|
+
# Skip binary diffs or very small metadata updates
|
|
37
|
+
if "Binary files" in diff_content or len(diff_content.strip()) < 20:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
user_prompt = USER_PROMPT_TEMPLATE.format(
|
|
41
|
+
filename=filename,
|
|
42
|
+
diff_content=trim_diff(diff_content, max_chars=15000) # Cap file diff size
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
messages = [
|
|
46
|
+
SystemMessage(content=REVIEW_SYSTEM_PROMPT),
|
|
47
|
+
HumanMessage(content=user_prompt)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response = llm.invoke(messages)
|
|
52
|
+
parsed = extract_json(response.content)
|
|
53
|
+
|
|
54
|
+
# Extract score and findings
|
|
55
|
+
score = float(parsed.get("score", 10.0))
|
|
56
|
+
scores.append(score)
|
|
57
|
+
|
|
58
|
+
findings = parsed.get("findings", [])
|
|
59
|
+
if isinstance(findings, list):
|
|
60
|
+
for finding in findings:
|
|
61
|
+
# Annotate with the filename
|
|
62
|
+
finding["file"] = filename
|
|
63
|
+
all_findings.append(finding)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
# Log or handle exception per file, but don't crash the whole review
|
|
66
|
+
# We can add a fallback warning finding
|
|
67
|
+
all_findings.append({
|
|
68
|
+
"file": filename,
|
|
69
|
+
"category": "suggestion",
|
|
70
|
+
"severity": "info",
|
|
71
|
+
"line": None,
|
|
72
|
+
"description": f"AI review failed for this file: {e}",
|
|
73
|
+
"fix": None
|
|
74
|
+
})
|
|
75
|
+
scores.append(10.0)
|
|
76
|
+
|
|
77
|
+
overall_score = sum(scores) / len(scores) if scores else 10.0
|
|
78
|
+
# Round overall score
|
|
79
|
+
overall_score = round(overall_score, 1)
|
|
80
|
+
|
|
81
|
+
return all_findings, overall_score
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
2
|
+
from ace.core.git_ops import GitOps
|
|
3
|
+
from ace.core.context import RepoContext
|
|
4
|
+
from ace.ai.llm_factory import get_llm
|
|
5
|
+
from ace.utils.diff_parser import trim_diff
|
|
6
|
+
from ace.ai.prompts.commit import (
|
|
7
|
+
CONVENTIONAL_COMMIT_SYSTEM_PROMPT,
|
|
8
|
+
SIMPLE_COMMIT_SYSTEM_PROMPT,
|
|
9
|
+
DETAILED_COMMIT_SYSTEM_PROMPT,
|
|
10
|
+
USER_PROMPT_TEMPLATE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
class NoStagedChangesError(Exception):
|
|
14
|
+
"""Raised when trying to generate a commit message but no changes are staged."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class CommitGenerator:
|
|
18
|
+
def __init__(self, git_ops: GitOps):
|
|
19
|
+
self.git_ops = git_ops
|
|
20
|
+
self.context_builder = RepoContext(git_ops)
|
|
21
|
+
|
|
22
|
+
def generate_message(self, format_type: str = "conventional", offline: bool = False) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Analyze staged changes and generate a commit message using the configured AI.
|
|
25
|
+
"""
|
|
26
|
+
# Ensure we have staged changes
|
|
27
|
+
status = self.git_ops.get_status()
|
|
28
|
+
if not status["staged"]:
|
|
29
|
+
raise NoStagedChangesError("No changes are staged for commit. Stage files first using 'git add'.")
|
|
30
|
+
|
|
31
|
+
staged_diff = self.git_ops.get_staged_diff()
|
|
32
|
+
if not staged_diff.strip():
|
|
33
|
+
# Sometimes status has staged but diff is empty (e.g. only file permissions or empty files)
|
|
34
|
+
raise NoStagedChangesError("Staged diff is empty. Cannot generate commit message.")
|
|
35
|
+
|
|
36
|
+
# Format context
|
|
37
|
+
repo_context = self.context_builder.format_context_for_prompt()
|
|
38
|
+
|
|
39
|
+
# Select system prompt based on format
|
|
40
|
+
if format_type == "conventional":
|
|
41
|
+
system_prompt = CONVENTIONAL_COMMIT_SYSTEM_PROMPT
|
|
42
|
+
elif format_type == "simple":
|
|
43
|
+
system_prompt = SIMPLE_COMMIT_SYSTEM_PROMPT
|
|
44
|
+
elif format_type == "detailed":
|
|
45
|
+
system_prompt = DETAILED_COMMIT_SYSTEM_PROMPT
|
|
46
|
+
else:
|
|
47
|
+
system_prompt = CONVENTIONAL_COMMIT_SYSTEM_PROMPT
|
|
48
|
+
|
|
49
|
+
user_prompt = USER_PROMPT_TEMPLATE.format(
|
|
50
|
+
repo_context=repo_context,
|
|
51
|
+
staged_diff=trim_diff(staged_diff, max_chars=25000) # Cap diff to avoid context window limit
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
messages = [
|
|
55
|
+
SystemMessage(content=system_prompt),
|
|
56
|
+
HumanMessage(content=user_prompt)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Get LLM and run inference
|
|
60
|
+
llm = get_llm(offline_override=offline)
|
|
61
|
+
response = llm.invoke(messages)
|
|
62
|
+
|
|
63
|
+
# Clean response (remove extra leading/trailing whitespace or markdown fences)
|
|
64
|
+
message = response.content.strip()
|
|
65
|
+
if message.startswith("```"):
|
|
66
|
+
lines = message.splitlines()
|
|
67
|
+
if lines[0].startswith("```"):
|
|
68
|
+
lines = lines[1:]
|
|
69
|
+
if lines and lines[-1].startswith("```"):
|
|
70
|
+
lines = lines[:-1]
|
|
71
|
+
message = "\n".join(lines).strip()
|
|
72
|
+
|
|
73
|
+
return message
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, Any, List, Tuple
|
|
3
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
4
|
+
from ace.core.git_ops import GitOps
|
|
5
|
+
from ace.utils.conflict_parser import parse_conflict_file
|
|
6
|
+
from ace.ai.llm_factory import get_llm
|
|
7
|
+
from ace.ai.prompts.conflict import CONFLICT_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
8
|
+
from ace.utils.json_utils import extract_json
|
|
9
|
+
|
|
10
|
+
class ConflictResolverError(Exception):
|
|
11
|
+
"""Raised when conflict resolution fails."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class ConflictResolver:
|
|
15
|
+
def __init__(self, git_ops: GitOps):
|
|
16
|
+
self.git_ops = git_ops
|
|
17
|
+
|
|
18
|
+
def get_suggestions(self, file_path: str, offline: bool = False) -> List[Dict[str, Any]]:
|
|
19
|
+
"""
|
|
20
|
+
Scan a file for conflicts and return AI resolution suggestions for each.
|
|
21
|
+
|
|
22
|
+
Returns a list of dicts:
|
|
23
|
+
[
|
|
24
|
+
{
|
|
25
|
+
"full_block": str,
|
|
26
|
+
"head": str,
|
|
27
|
+
"incoming": str,
|
|
28
|
+
"suggested_merged": str,
|
|
29
|
+
"explanation": str
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
"""
|
|
33
|
+
full_path = Path(self.git_ops.working_dir) / file_path
|
|
34
|
+
if not full_path.exists():
|
|
35
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
content = full_path.read_text(encoding="utf-8")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise ConflictResolverError(f"Failed to read file {file_path}: {e}")
|
|
41
|
+
|
|
42
|
+
blocks = parse_conflict_file(content)
|
|
43
|
+
if not blocks:
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
llm = get_llm(offline_override=offline)
|
|
47
|
+
suggestions = []
|
|
48
|
+
|
|
49
|
+
for block in blocks:
|
|
50
|
+
sys_prompt = CONFLICT_SYSTEM_PROMPT.format(filename=file_path)
|
|
51
|
+
usr_prompt = USER_PROMPT_TEMPLATE.format(
|
|
52
|
+
head_content=block["head"],
|
|
53
|
+
incoming_content=block["incoming"]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
messages = [
|
|
57
|
+
SystemMessage(content=sys_prompt),
|
|
58
|
+
HumanMessage(content=usr_prompt)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
response = llm.invoke(messages)
|
|
63
|
+
parsed = extract_json(response.content)
|
|
64
|
+
|
|
65
|
+
suggestions.append({
|
|
66
|
+
"full_block": block["full_block"],
|
|
67
|
+
"head": block["head"],
|
|
68
|
+
"incoming": block["incoming"],
|
|
69
|
+
"suggested_merged": parsed.get("merged_content", block["head"]), # Fallback to head
|
|
70
|
+
"explanation": parsed.get("explanation", "AI suggested resolution.")
|
|
71
|
+
})
|
|
72
|
+
except Exception as e:
|
|
73
|
+
# Fallback in case of AI failures
|
|
74
|
+
suggestions.append({
|
|
75
|
+
"full_block": block["full_block"],
|
|
76
|
+
"head": block["head"],
|
|
77
|
+
"incoming": block["incoming"],
|
|
78
|
+
"suggested_merged": block["head"], # Keep head as fallback
|
|
79
|
+
"explanation": f"AI suggestion failed: {e}. Keeping local branch version as default suggestion."
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return suggestions
|
|
83
|
+
|
|
84
|
+
def apply_resolution(self, file_path: str, block_replacements: List[Tuple[str, str]]) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Apply resolutions to a conflicted file.
|
|
87
|
+
|
|
88
|
+
block_replacements: List of tuples (full_conflict_block, replacement_content)
|
|
89
|
+
"""
|
|
90
|
+
full_path = Path(self.git_ops.working_dir) / file_path
|
|
91
|
+
if not full_path.exists():
|
|
92
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
93
|
+
|
|
94
|
+
content = full_path.read_text(encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
for full_block, replacement in block_replacements:
|
|
97
|
+
if full_block in content:
|
|
98
|
+
content = content.replace(full_block, replacement)
|
|
99
|
+
else:
|
|
100
|
+
# Try with normalized line endings
|
|
101
|
+
norm_block = full_block.replace("\r\n", "\n")
|
|
102
|
+
norm_content = content.replace("\r\n", "\n")
|
|
103
|
+
if norm_block in norm_content:
|
|
104
|
+
norm_content = norm_content.replace(norm_block, replacement)
|
|
105
|
+
# Restore Windows line endings if they were originally present
|
|
106
|
+
if "\r\n" in content:
|
|
107
|
+
content = norm_content.replace("\n", "\r\n")
|
|
108
|
+
else:
|
|
109
|
+
content = norm_content
|
|
110
|
+
else:
|
|
111
|
+
raise ConflictResolverError(
|
|
112
|
+
"Conflict block not found in file. Has it been edited already?"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
full_path.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
4
|
+
from ace.core.git_ops import GitOps
|
|
5
|
+
from ace.ai.llm_factory import get_llm
|
|
6
|
+
from ace.ai.prompts.ignore import IGNORE_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
7
|
+
from ace.utils.json_utils import extract_json
|
|
8
|
+
|
|
9
|
+
class GitignoreGenerator:
|
|
10
|
+
def __init__(self, git_ops: GitOps):
|
|
11
|
+
self.git_ops = git_ops
|
|
12
|
+
|
|
13
|
+
def generate_rules(self, query: str, offline: bool = False) -> Dict[str, Any]:
|
|
14
|
+
"""
|
|
15
|
+
Reads the current .gitignore file (if any), queries the LLM with the user's
|
|
16
|
+
ignore request, and returns generated rules to append along with an explanation.
|
|
17
|
+
"""
|
|
18
|
+
gitignore_path = os.path.join(self.git_ops.working_dir, ".gitignore")
|
|
19
|
+
current_content = ""
|
|
20
|
+
if os.path.exists(gitignore_path):
|
|
21
|
+
try:
|
|
22
|
+
with open(gitignore_path, "r", encoding="utf-8") as f:
|
|
23
|
+
current_content = f.read()
|
|
24
|
+
except Exception:
|
|
25
|
+
current_content = ""
|
|
26
|
+
|
|
27
|
+
llm = get_llm(offline_override=offline)
|
|
28
|
+
|
|
29
|
+
usr_prompt = USER_PROMPT_TEMPLATE.format(
|
|
30
|
+
query=query,
|
|
31
|
+
current_gitignore=current_content.strip() or "(empty)"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
messages = [
|
|
35
|
+
SystemMessage(content=IGNORE_SYSTEM_PROMPT),
|
|
36
|
+
HumanMessage(content=usr_prompt)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
response = llm.invoke(messages)
|
|
40
|
+
parsed = extract_json(response.content)
|
|
41
|
+
|
|
42
|
+
if "rules" not in parsed or "explanation" not in parsed:
|
|
43
|
+
raise Exception("AI response JSON missing 'rules' or 'explanation' keys.")
|
|
44
|
+
|
|
45
|
+
return parsed
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import os
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
6
|
+
from ace.core.git_ops import GitOps
|
|
7
|
+
from ace.ai.llm_factory import get_llm
|
|
8
|
+
|
|
9
|
+
HISTORY_SUMMARY_SYSTEM_PROMPT = """
|
|
10
|
+
You are "Ace", the AI Git Copilot.
|
|
11
|
+
The user asked a question about the repository's Git history, and we ran a Git command to fetch the raw data.
|
|
12
|
+
Your job is to analyze the raw Git output and summarize it for the user in a beautiful, helpful, and concise format.
|
|
13
|
+
|
|
14
|
+
User Query: "{query}"
|
|
15
|
+
Git Command Run: "{command}"
|
|
16
|
+
|
|
17
|
+
Analyze the Git output below and write the response. Use markdown, emojis, lists, or tables where helpful. Keep it concise.
|
|
18
|
+
""".strip()
|
|
19
|
+
|
|
20
|
+
class HistoryAnalyzer:
|
|
21
|
+
def __init__(self, git_ops: GitOps):
|
|
22
|
+
self.git_ops = git_ops
|
|
23
|
+
|
|
24
|
+
def summarize_query(self, query: str, command: str, command_output: str, offline: bool = False) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Summarize raw Git command output in response to a user's question.
|
|
27
|
+
"""
|
|
28
|
+
if not command_output.strip():
|
|
29
|
+
return "The Git command returned no output, meaning there is no matching history found."
|
|
30
|
+
|
|
31
|
+
llm = get_llm(offline_override=offline)
|
|
32
|
+
|
|
33
|
+
sys_prompt = HISTORY_SUMMARY_SYSTEM_PROMPT.format(query=query, command=command)
|
|
34
|
+
user_prompt = f"Raw Git Output:\n\"\"\"\n{command_output[:20000]}\n\"\"\""
|
|
35
|
+
|
|
36
|
+
messages = [
|
|
37
|
+
SystemMessage(content=sys_prompt),
|
|
38
|
+
HumanMessage(content=user_prompt)
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
response = llm.invoke(messages)
|
|
42
|
+
return response.content.strip()
|
|
43
|
+
|
|
44
|
+
def get_repo_stats(self) -> Dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Calculate repository statistics:
|
|
47
|
+
- Total commits
|
|
48
|
+
- Unique contributors
|
|
49
|
+
- Most active authors
|
|
50
|
+
- Recent commit frequency
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
# Get list of all commits (hash|author|date)
|
|
54
|
+
log_data = self.git_ops.execute("log --format=%H|%an|%ad")
|
|
55
|
+
except Exception:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
commits = [line.split("|") for line in log_data.splitlines() if "|" in line]
|
|
59
|
+
if not commits:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
total_commits = len(commits)
|
|
63
|
+
|
|
64
|
+
# Contributors count
|
|
65
|
+
authors = [c[1] for c in commits]
|
|
66
|
+
author_counts = collections.Counter(authors)
|
|
67
|
+
contributors = list(author_counts.items())
|
|
68
|
+
# Sort contributors by commit count descending
|
|
69
|
+
contributors.sort(key=lambda x: x[1], reverse=True)
|
|
70
|
+
|
|
71
|
+
# Active branches
|
|
72
|
+
branches = self.git_ops.get_branches()
|
|
73
|
+
|
|
74
|
+
# Files changed counts (approximate from diff-tree of recent commits)
|
|
75
|
+
status = self.git_ops.get_status()
|
|
76
|
+
|
|
77
|
+
# Lines of code changes per author
|
|
78
|
+
try:
|
|
79
|
+
numstat_data = self.git_ops.execute('log --numstat --format=AUTHOR:%an')
|
|
80
|
+
except Exception:
|
|
81
|
+
numstat_data = ""
|
|
82
|
+
|
|
83
|
+
author_lines = {}
|
|
84
|
+
current_author = None
|
|
85
|
+
for line in numstat_data.splitlines():
|
|
86
|
+
line = line.strip().replace('"', '')
|
|
87
|
+
if not line:
|
|
88
|
+
continue
|
|
89
|
+
if line.startswith("AUTHOR:"):
|
|
90
|
+
current_author = line[7:]
|
|
91
|
+
if current_author not in author_lines:
|
|
92
|
+
author_lines[current_author] = {"added": 0, "deleted": 0}
|
|
93
|
+
elif current_author:
|
|
94
|
+
parts = line.split()
|
|
95
|
+
if len(parts) >= 3:
|
|
96
|
+
try:
|
|
97
|
+
added = int(parts[0])
|
|
98
|
+
deleted = int(parts[1])
|
|
99
|
+
author_lines[current_author]["added"] += added
|
|
100
|
+
author_lines[current_author]["deleted"] += deleted
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# File type distribution (extensions)
|
|
105
|
+
try:
|
|
106
|
+
files_list = self.git_ops.execute("ls-files")
|
|
107
|
+
except Exception:
|
|
108
|
+
files_list = ""
|
|
109
|
+
|
|
110
|
+
extensions = []
|
|
111
|
+
for f in files_list.splitlines():
|
|
112
|
+
f = f.strip()
|
|
113
|
+
if not f:
|
|
114
|
+
continue
|
|
115
|
+
_, ext = os.path.splitext(f)
|
|
116
|
+
if ext:
|
|
117
|
+
extensions.append(ext.lower())
|
|
118
|
+
else:
|
|
119
|
+
extensions.append("(no extension)")
|
|
120
|
+
|
|
121
|
+
extension_counts = collections.Counter(extensions)
|
|
122
|
+
|
|
123
|
+
# Commit timeline (last 14 days)
|
|
124
|
+
try:
|
|
125
|
+
dates_data = self.git_ops.execute("log --format=%ad --date=short")
|
|
126
|
+
except Exception:
|
|
127
|
+
dates_data = ""
|
|
128
|
+
|
|
129
|
+
date_counts = collections.Counter(dates_data.splitlines())
|
|
130
|
+
today = datetime.date.today()
|
|
131
|
+
last_14_days = [today - datetime.timedelta(days=i) for i in range(13, -1, -1)]
|
|
132
|
+
|
|
133
|
+
timeline = []
|
|
134
|
+
for d in last_14_days:
|
|
135
|
+
date_str = d.isoformat()
|
|
136
|
+
count = date_counts.get(date_str, 0)
|
|
137
|
+
timeline.append((date_str, count))
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"total_commits": total_commits,
|
|
141
|
+
"total_branches": len(branches),
|
|
142
|
+
"contributors": contributors,
|
|
143
|
+
"staged_count": len(status["staged"]),
|
|
144
|
+
"unstaged_count": len(status["unstaged"]),
|
|
145
|
+
"untracked_count": len(status["untracked"]),
|
|
146
|
+
"lines_per_author": author_lines,
|
|
147
|
+
"extension_counts": dict(extension_counts.most_common(5)),
|
|
148
|
+
"timeline": timeline,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def semantic_search(self, query: str, limit: int = 50, offline: bool = False) -> Dict[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
Retrieves the last `limit` commits, sends them to the LLM along with the search query,
|
|
155
|
+
and returns the semantically matching commits.
|
|
156
|
+
"""
|
|
157
|
+
commits = self.git_ops.get_log(n=limit)
|
|
158
|
+
if not commits:
|
|
159
|
+
return {"matches": []}
|
|
160
|
+
|
|
161
|
+
history_lines = []
|
|
162
|
+
for c in commits:
|
|
163
|
+
history_lines.append(
|
|
164
|
+
f"Commit: {c['hexsha']}\nAuthor: {c['author']}\nDate: {c['date']}\nSummary: {c['summary']}\nMessage: {c['message']}\n---"
|
|
165
|
+
)
|
|
166
|
+
commit_history_text = "\n".join(history_lines)
|
|
167
|
+
|
|
168
|
+
from ace.ai.prompts.search import SEARCH_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
169
|
+
from ace.utils.json_utils import extract_json
|
|
170
|
+
|
|
171
|
+
llm = get_llm(offline_override=offline)
|
|
172
|
+
|
|
173
|
+
if len(commit_history_text) > 25000:
|
|
174
|
+
commit_history_text = commit_history_text[:25000] + "\n\n... (history truncated due to size) ..."
|
|
175
|
+
|
|
176
|
+
usr_prompt = USER_PROMPT_TEMPLATE.format(
|
|
177
|
+
query=query,
|
|
178
|
+
commit_history=commit_history_text
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
messages = [
|
|
182
|
+
SystemMessage(content=SEARCH_SYSTEM_PROMPT),
|
|
183
|
+
HumanMessage(content=usr_prompt)
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
response = llm.invoke(messages)
|
|
187
|
+
return extract_json(response.content)
|
|
188
|
+
|
ace/ai/intent_parser.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
3
|
+
from ace.core.git_ops import GitOps
|
|
4
|
+
from ace.core.context import RepoContext
|
|
5
|
+
from ace.ai.llm_factory import get_llm
|
|
6
|
+
from ace.ai.prompts.intent import INTENT_SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
|
7
|
+
from ace.utils.json_utils import extract_json, JSONExtractError
|
|
8
|
+
|
|
9
|
+
class IntentParserError(Exception):
|
|
10
|
+
"""Raised when intent parsing fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class IntentParser:
|
|
14
|
+
def __init__(self, git_ops: GitOps):
|
|
15
|
+
self.git_ops = git_ops
|
|
16
|
+
self.context_builder = RepoContext(git_ops)
|
|
17
|
+
|
|
18
|
+
def parse_intent(self, query: str, offline: bool = False) -> Dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Convert a natural language query into structured Git commands.
|
|
21
|
+
|
|
22
|
+
Returns a dict:
|
|
23
|
+
{
|
|
24
|
+
"commands": List[str],
|
|
25
|
+
"explanation": str,
|
|
26
|
+
"risk_level": "safe" | "moderate" | "destructive",
|
|
27
|
+
"alternatives": Optional[str]
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
# Fetch current context
|
|
31
|
+
repo_context = self.context_builder.format_context_for_prompt()
|
|
32
|
+
|
|
33
|
+
user_prompt = USER_PROMPT_TEMPLATE.format(
|
|
34
|
+
repo_context=repo_context,
|
|
35
|
+
query=query
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
messages = [
|
|
39
|
+
SystemMessage(content=INTENT_SYSTEM_PROMPT),
|
|
40
|
+
HumanMessage(content=user_prompt)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Initialize and call LLM
|
|
44
|
+
llm = get_llm(offline_override=offline)
|
|
45
|
+
response = llm.invoke(messages)
|
|
46
|
+
|
|
47
|
+
raw_content = response.content.strip()
|
|
48
|
+
|
|
49
|
+
# Parse the output using shared utility
|
|
50
|
+
try:
|
|
51
|
+
parsed = extract_json(raw_content)
|
|
52
|
+
except JSONExtractError as e:
|
|
53
|
+
raise IntentParserError(str(e))
|
|
54
|
+
|
|
55
|
+
# Validate keys and default types
|
|
56
|
+
commands = parsed.get("commands", [])
|
|
57
|
+
if not isinstance(commands, list):
|
|
58
|
+
commands = [str(commands)] if commands else []
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"commands": [cmd.strip() for cmd in commands],
|
|
62
|
+
"explanation": str(parsed.get("explanation", "No explanation provided.")),
|
|
63
|
+
"risk_level": str(parsed.get("risk_level", "safe")).lower(),
|
|
64
|
+
"alternatives": parsed.get("alternatives", None)
|
|
65
|
+
}
|