diff --git a/src/json_extract/__init__.py b/src/json_extract/__init__.py new file mode 100644 index 0000000..ea0d54b --- /dev/null +++ b/src/json_extract/__init__.py @@ -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)} diff --git a/src/json_extract/test_json_extract.py b/src/json_extract/test_json_extract.py new file mode 100644 index 0000000..fe9a334 --- /dev/null +++ b/src/json_extract/test_json_extract.py @@ -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() diff --git a/src/jujutsu/__init__.py b/src/jujutsu/__init__.py new file mode 100644 index 0000000..d949b5f --- /dev/null +++ b/src/jujutsu/__init__.py @@ -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, + ) diff --git a/src/jujutsu/command.py b/src/jujutsu/command.py new file mode 100644 index 0000000..2114609 --- /dev/null +++ b/src/jujutsu/command.py @@ -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)