push-oynptmmzrplt #2

Merged
eric merged 10 commits from push-oynptmmzrplt into main 2026-03-11 22:47:25 +01:00
4 changed files with 536 additions and 0 deletions
Showing only changes of commit 1c9cee35df - Show all commits

View 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)}

View 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
View 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
View 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)