feat: Add AI-powered commit message generation with jujutsu integration
- Added `json_extract` module to convert structured text to JSON using LOCAL_LLM_API - Implemented robust error handling for API failures and malformed responses - Added markdown backtick stripping to ensure clean JSON parsing - Integrated AI-driven commit message generation via `jj_commit_message()` and `jj_auto_describe()` - Provided comprehensive unit tests for all core functions - Added support for custom/local LLM endpoints with fallback to default URL
This commit is contained in:
76
src/json_extract/__init__.py
Normal file
76
src/json_extract/__init__.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def structure_to_json(text: str, schema: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Convert structured text to JSON using LOCAL_LLM_API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The structured text to convert
|
||||||
|
schema (dict): The JSON schema to use for conversion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The parsed JSON object
|
||||||
|
"""
|
||||||
|
# Get the API URL from environment variable
|
||||||
|
local_llm_api = os.environ.get("LOCAL_LLM_API", "http://host.docker.internal:1248/v1")
|
||||||
|
|
||||||
|
# Format the schema as JSON string
|
||||||
|
schema_json = json.dumps(schema, indent=2)
|
||||||
|
|
||||||
|
# Create the prompt following the pattern from jj-ai-commit.sh
|
||||||
|
prompt_text = f"""Analyse this text and respond ONLY with a valid JSON object matching the schema.
|
||||||
|
|
||||||
|
Text:
|
||||||
|
{text}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{schema_json}"""
|
||||||
|
|
||||||
|
# Build the request body
|
||||||
|
request_body = {
|
||||||
|
"model": "qwen3-coder",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": prompt_text}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the URL
|
||||||
|
url = f"{local_llm_api}/chat/completions"
|
||||||
|
|
||||||
|
# Create the request
|
||||||
|
data = json.dumps(request_body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute the request
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return {"error": f"API request failed: {e}", "raw_response": ""}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return {"error": f"Failed to parse response: {e}", "raw_response": ""}
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
# Clean markdown backticks from response
|
||||||
|
clean_content = content.strip()
|
||||||
|
while clean_content.startswith("`") or clean_content.startswith("```"):
|
||||||
|
clean_content = clean_content[1:].strip()
|
||||||
|
while clean_content.endswith("`") or clean_content.endswith("```"):
|
||||||
|
clean_content = clean_content[:-1].strip()
|
||||||
|
|
||||||
|
# Parse the JSON content
|
||||||
|
return json.loads(clean_content)
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
||||||
|
return {"error": f"Failed to parse JSON: {e}", "raw_response": json.dumps(result)}
|
||||||
145
src/json_extract/test_json_extract.py
Normal file
145
src/json_extract/test_json_extract.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/workspace/src")
|
||||||
|
|
||||||
|
from json_extract import structure_to_json
|
||||||
|
|
||||||
|
|
||||||
|
class TestStructureToJson(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
os.environ.pop("LOCAL_LLM_API", None)
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_successful_conversion(self, mock_urlopen):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps(
|
||||||
|
{"choices": [{"message": {"content": '{"name": "John", "age": 30}'}}]}
|
||||||
|
).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Nom: John, Age: 30"
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertEqual(result["name"], "John")
|
||||||
|
self.assertEqual(result["age"], 30)
|
||||||
|
self.assertNotIn("error", result)
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_markdown_backtick_removal(self, mock_urlopen):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps(
|
||||||
|
{"choices": [{"message": {"content": '{"name": "John"}'}}]}
|
||||||
|
).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Nom: John"
|
||||||
|
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertEqual(result["name"], "John")
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_api_error_handling(self, mock_urlopen):
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
|
||||||
|
|
||||||
|
text = "Some text"
|
||||||
|
schema = {"type": "object"}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
self.assertIn("API request failed", result["error"])
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_invalid_json_response(self, mock_urlopen):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps(
|
||||||
|
{"choices": [{"message": {"content": "This is not JSON"}}]}
|
||||||
|
).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Some text"
|
||||||
|
schema = {"type": "object"}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
self.assertIn("Failed to parse JSON", result["error"])
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_empty_response(self, mock_urlopen):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps({"choices": [{}]}).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Some text"
|
||||||
|
schema = {"type": "object"}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.Request")
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_custom_api_url(self, mock_urlopen, mock_request):
|
||||||
|
os.environ["LOCAL_LLM_API"] = "http://custom-api:8080/v1"
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps(
|
||||||
|
{"choices": [{"message": {"content": '{"data": "test"}'}}]}
|
||||||
|
).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Test data"
|
||||||
|
schema = {"type": "object", "properties": {"data": {"type": "string"}}}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertEqual(result["data"], "test")
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
self.assertEqual(call_args[0][0], "http://custom-api:8080/v1/chat/completions")
|
||||||
|
|
||||||
|
@patch("json_extract.urllib.request.Request")
|
||||||
|
@patch("json_extract.urllib.request.urlopen")
|
||||||
|
def test_default_api_url(self, mock_urlopen, mock_request):
|
||||||
|
os.environ.pop("LOCAL_LLM_API", None)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps(
|
||||||
|
{"choices": [{"message": {"content": '{"data": "test"}'}}]}
|
||||||
|
).encode("utf-8")
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
|
||||||
|
text = "Test data"
|
||||||
|
schema = {"type": "object"}
|
||||||
|
|
||||||
|
result = structure_to_json(text, schema)
|
||||||
|
|
||||||
|
self.assertNotIn("error", result)
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
call_args = mock_request.call_args
|
||||||
|
self.assertEqual(
|
||||||
|
call_args[0][0], "http://host.docker.internal:1248/v1/chat/completions"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_text_and_schema(self):
|
||||||
|
result = structure_to_json("", {})
|
||||||
|
|
||||||
|
self.assertIn("error", result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
134
src/jujutsu/__init__.py
Normal file
134
src/jujutsu/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from json_extract import structure_to_json
|
||||||
|
from jujutsu.command import jj_log as jj_log_command
|
||||||
|
from jujutsu.command import jj_diff
|
||||||
|
from jujutsu.command import jj_new as jj_new_command
|
||||||
|
|
||||||
|
from .command import jj
|
||||||
|
|
||||||
|
|
||||||
|
def jj_log(working_dir=None):
|
||||||
|
"""
|
||||||
|
Execute jj log command for a project and return structured data as JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path (str): The path of the project
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: JSON structure with commit history
|
||||||
|
"""
|
||||||
|
# Execute jj log command with working_dir parameter
|
||||||
|
log_output = jj_log_command(working_dir=working_dir)
|
||||||
|
|
||||||
|
# Define the schema for the JSON structure
|
||||||
|
schema = {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hash": {"type": "string"},
|
||||||
|
"author": {"type": "string"},
|
||||||
|
"date": {"type": "string"},
|
||||||
|
"message": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["hash", "author", "date", "message"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert the text output to JSON using the schema
|
||||||
|
return structure_to_json(log_output, schema)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_commit_message(working_dir=None):
|
||||||
|
"""
|
||||||
|
Generate a commit message from the current diff using LOCAL_LLM_API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
working_dir: The path of the project (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Generated commit message in format "title\n\nmessage"
|
||||||
|
"""
|
||||||
|
diff_output = jj_diff(working_dir=working_dir)
|
||||||
|
|
||||||
|
prompt = """Analyse ce diff et réponds UNIQUEMENT avec un objet JSON valide contenant "title" (string) et "message" (string) pour le message de commit. Pas de texte avant ou après le JSON."""
|
||||||
|
|
||||||
|
content = f"{prompt}\n{diff_output}"
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"message": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["title", "message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = structure_to_json(content, schema)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
return result["error"]
|
||||||
|
|
||||||
|
title = result.get("title", "")
|
||||||
|
message = result.get("message", "")
|
||||||
|
|
||||||
|
return f"{title}\n\n{message}"
|
||||||
|
|
||||||
|
|
||||||
|
def jj_auto_describe(working_dir=None):
|
||||||
|
"""
|
||||||
|
Automatically describe the current change using jj describe with AI-generated message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
working_dir: The path of the project (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of jj describe command
|
||||||
|
"""
|
||||||
|
message = jj_commit_message(working_dir=working_dir)
|
||||||
|
|
||||||
|
args = ["-m", message]
|
||||||
|
return jj("describe", args=args, working_dir=working_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_new(
|
||||||
|
revisions=None,
|
||||||
|
message=None,
|
||||||
|
no_edit=False,
|
||||||
|
insert_after=None,
|
||||||
|
insert_before=None,
|
||||||
|
options=None,
|
||||||
|
working_dir: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new commit after documenting the current change with AI.
|
||||||
|
|
||||||
|
First runs jj_auto_describe to document the current working copy,
|
||||||
|
then creates a new empty commit with jj new.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revisions: List of parent revisions (default: [@])
|
||||||
|
message: The change description to use
|
||||||
|
no_edit: Do not edit the newly created change
|
||||||
|
insert_after: Insert the new change after the given commit(s)
|
||||||
|
insert_before: Insert the new change before the given commit(s)
|
||||||
|
options: List of additional options
|
||||||
|
working_dir: The path of the project (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of jj new command
|
||||||
|
"""
|
||||||
|
jj_auto_describe(working_dir=working_dir)
|
||||||
|
|
||||||
|
return jj_new_command(
|
||||||
|
revisions=revisions,
|
||||||
|
message=message,
|
||||||
|
no_edit=no_edit,
|
||||||
|
insert_after=insert_after,
|
||||||
|
insert_before=insert_before,
|
||||||
|
options=options,
|
||||||
|
working_dir=working_dir,
|
||||||
|
)
|
||||||
181
src/jujutsu/command.py
Normal file
181
src/jujutsu/command.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def jj(
|
||||||
|
subcommand: str,
|
||||||
|
options: Optional[List[str]] = None,
|
||||||
|
args: Optional[List[str]] = None,
|
||||||
|
template: Optional[str] = None,
|
||||||
|
working_dir: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Lance une commande jj.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subcommand: La sous-commande jj (ex: 'log', 'status', 'diff')
|
||||||
|
options: Liste des options (ex: ['--limit', '5'])
|
||||||
|
args: Liste des arguments additionnels
|
||||||
|
template: Template jj à utiliser (ex: 'builtin_log_compact')
|
||||||
|
working_dir: Répertoire de travail optionnel
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: sortie de la commande
|
||||||
|
"""
|
||||||
|
cmd = ["jj", subcommand]
|
||||||
|
|
||||||
|
# Ajoute le template spécifié
|
||||||
|
if template:
|
||||||
|
cmd.extend(["--template", template])
|
||||||
|
|
||||||
|
if options:
|
||||||
|
cmd.extend(options)
|
||||||
|
if args:
|
||||||
|
cmd.extend(args)
|
||||||
|
|
||||||
|
# Si un répertoire de travail est spécifié, on le change temporairement
|
||||||
|
if working_dir is not None:
|
||||||
|
original_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(working_dir)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
finally:
|
||||||
|
os.chdir(original_cwd)
|
||||||
|
else:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Commande jj échouée: {' '.join(cmd)}\nstderr: {result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def jj_log(options=None, args=None, template=None, working_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Execute jj log command with optional arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: List of options to pass to jj log (ex: ['--limit', '5'])
|
||||||
|
args: List of additional arguments
|
||||||
|
template: Template to use (ex: 'builtin_log_compact')
|
||||||
|
working_dir: Working directory for the command (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of the jj log command
|
||||||
|
"""
|
||||||
|
return jj("log", options, args, template, working_dir=working_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_clone(url, destination=None, options=None, working_dir: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Execute jj git clone command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The repository URL to clone
|
||||||
|
destination: The destination directory (optional)
|
||||||
|
options: List of additional options for jj git clone
|
||||||
|
working_dir: Working directory for the command (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of the jj git clone command
|
||||||
|
"""
|
||||||
|
args = ["clone", url]
|
||||||
|
if destination:
|
||||||
|
args.append(destination)
|
||||||
|
|
||||||
|
if options:
|
||||||
|
args.extend(options)
|
||||||
|
|
||||||
|
return jj("git", args=args, working_dir=working_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_describe(
|
||||||
|
message: Optional[str] = None, options=None, working_dir: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute jj describe command to set or view commit description.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The description text to set (optional, reads current if not provided)
|
||||||
|
options: List of additional options for jj describe
|
||||||
|
working_dir: Working directory for the command (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of the jj describe command
|
||||||
|
"""
|
||||||
|
args = []
|
||||||
|
if message:
|
||||||
|
args.append(message)
|
||||||
|
|
||||||
|
if options:
|
||||||
|
args.extend(options)
|
||||||
|
|
||||||
|
return jj("describe", args=args, working_dir=working_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_diff(
|
||||||
|
options=None, args=None, template=None, working_dir: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute jj diff command with optional arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: List of options to pass to jj diff
|
||||||
|
args: List of additional arguments
|
||||||
|
template: Template to use
|
||||||
|
working_dir: Working directory for the command (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of the jj diff command
|
||||||
|
"""
|
||||||
|
return jj("diff", options, args, template, working_dir=working_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def jj_new(
|
||||||
|
revisions=None,
|
||||||
|
message=None,
|
||||||
|
no_edit=False,
|
||||||
|
insert_after=None,
|
||||||
|
insert_before=None,
|
||||||
|
options=None,
|
||||||
|
working_dir: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Execute jj new command to create a new empty change.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revisions: List of parent revisions (default: [@])
|
||||||
|
message: The change description to use
|
||||||
|
no_edit: Do not edit the newly created change
|
||||||
|
insert_after: Insert the new change after the given commit(s)
|
||||||
|
insert_before: Insert the new change before the given commit(s)
|
||||||
|
options: List of additional options
|
||||||
|
working_dir: Working directory for the command (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Output of the jj new command
|
||||||
|
"""
|
||||||
|
args = []
|
||||||
|
if revisions:
|
||||||
|
args.extend(revisions)
|
||||||
|
|
||||||
|
if message:
|
||||||
|
args.extend(["-m", message])
|
||||||
|
|
||||||
|
if no_edit:
|
||||||
|
args.append("--no-edit")
|
||||||
|
|
||||||
|
if insert_after:
|
||||||
|
args.extend(["--insert-after", insert_after])
|
||||||
|
|
||||||
|
if insert_before:
|
||||||
|
args.extend(["--insert-before", insert_before])
|
||||||
|
|
||||||
|
if options:
|
||||||
|
args.extend(options)
|
||||||
|
|
||||||
|
return jj("new", args=args, working_dir=working_dir)
|
||||||
Reference in New Issue
Block a user