diff --git a/.gitignore b/.gitignore index 37205fb..d652275 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ config/ .files/ codetide/agents/tide/ui/assets/ +examples/hf_demo_space/.chainlit/* +examples/hf_demo_space/chainlit.md diff --git a/codetide/agents/tide/agent.py b/codetide/agents/tide/agent.py index 184963d..967bc8e 100644 --- a/codetide/agents/tide/agent.py +++ b/codetide/agents/tide/agent.py @@ -169,4 +169,9 @@ def _(event): # This can happen if the event loop is shut down pass finally: - _logger.logger.info("Exited by user. Goodbye!") \ No newline at end of file + _logger.logger.info("Exited by user. Goodbye!") + + async def _handle_commands(self, command :str): + # TODO add logic here to handlle git command, i.e stage files, write commit messages and checkout + # expand to support new branches + pass \ No newline at end of file diff --git a/codetide/agents/tide/ui/agent_tide_ui.py b/codetide/agents/tide/ui/agent_tide_ui.py index 553a6e5..00afceb 100644 --- a/codetide/agents/tide/ui/agent_tide_ui.py +++ b/codetide/agents/tide/ui/agent_tide_ui.py @@ -126,5 +126,7 @@ def settings(self): ) ] - def get_command_prompt(self, command :str)->Optional[str]: - return self.commands_prompts.get(command) + async def get_command_prompt(self, command :str)->Optional[str]: + await self.agent_tide._handle_commands(command) + + return self.commands_prompts.get(command) diff --git a/codetide/agents/tide/ui/app.py b/codetide/agents/tide/ui/app.py index 7c6b63f..ac9acb3 100644 --- a/codetide/agents/tide/ui/app.py +++ b/codetide/agents/tide/ui/app.py @@ -240,7 +240,7 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None chat_history = cl.user_session.get("chat_history") if message.command: - command_prompt = agent_tide_ui.get_command_prompt(message.command) + command_prompt = await agent_tide_ui.get_command_prompt(message.command) if command_prompt: message.content = "\n\n---\n\n".join([command_prompt, message.content]) diff --git a/examples/hf_demo_space/Dockerfile b/examples/hf_demo_space/Dockerfile index a9c2146..fd22816 100644 --- a/examples/hf_demo_space/Dockerfile +++ b/examples/hf_demo_space/Dockerfile @@ -17,6 +17,7 @@ RUN mkdir -p /app/.files && chmod 777 /app/.files RUN mkdir -p /app/logs && chmod 777 /app/logs RUN mkdir -p /app/observability_data && chmod 777 /app/observability_data RUN mkdir -p /app/storage && chmod 777 /app/storage +RUN mkdir -p /app/sessions && chmod 777 /app/sessions # Copy the current repository into the container COPY . /app diff --git a/examples/hf_demo_space/app.py b/examples/hf_demo_space/app.py index 00cf415..a28991c 100644 --- a/examples/hf_demo_space/app.py +++ b/examples/hf_demo_space/app.py @@ -10,6 +10,7 @@ from codetide.agents.tide.ui.utils import run_concurrent_tasks from codetide.agents.tide.ui.agent_tide_ui import AgentTideUi from codetide.core.defaults import DEFAULT_ENCODING +from codetide.core.logs import logger from codetide.agents.tide.models import Step from aicore.const import STREAM_END_TOKEN, STREAM_START_TOKEN @@ -17,47 +18,22 @@ from aicore.config import Config from aicore.llm import Llm -from typing import Optional +from git_utils import commit_and_push_changes, validate_git_url from chainlit.cli import run_chainlit +from typing import Optional from pathlib import Path from ulid import ulid import chainlit as cl import subprocess +import asyncio import shutil -import yaml import json +import stat +import yaml import os -import re DEFAULT_SESSIONS_WORKSPACE = Path(os.getcwd()) / "sessions" -GIT_URL_PATTERN = re.compile( - r'^(?:http|https|git|ssh)://' # Protocol - r'(?:\S+@)?' # Optional username - r'([^/]+)' # Domain - r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path -) - -def validate_git_url(url) -> None: - """Validate the Git repository URL using git ls-remote.""" - if not GIT_URL_PATTERN.match(url): - raise ValueError(f"Invalid Git repository URL format: {url}") - - try: - result = subprocess.run( - ["git", "ls-remote", url], - capture_output=True, - text=True, - check=True, - timeout=10 # Add timeout to prevent hanging - ) - if not result.stdout.strip(): - raise ValueError(f"URL {url} points to an empty repository") - except subprocess.TimeoutExpired: - raise ValueError(f"Timeout while validating URL {url}") - except subprocess.CalledProcessError as e: - raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e - async def validate_llm_config_hf(agent_tide_ui: AgentTideUi): exception = True session_id = cl.user_session.get("session_id") @@ -141,22 +117,35 @@ async def clone_repo(session_id): while exception: try: - url = await cl.AskUserMessage( + user_message = await cl.AskUserMessage( content="Provide a valid github url to give AgentTide some context!" ).send() - validate_git_url(url) + url = user_message.get("output") + await validate_git_url(url) exception = None except Exception as e: await cl.Message(f"Invalid url found, please provide only the url, if it is a private repo you can inlucde a PAT in the url: {e}").send() exception = e - subprocess.run( - ["git", "clone", "--no-checkout", url, DEFAULT_SESSIONS_WORKSPACE / session_id], - check=True, - capture_output=True, - text=True, - timeout=300 + logger.info(f"executing cmd git clone --no-checkout {url} {DEFAULT_SESSIONS_WORKSPACE / session_id}") + + process = await asyncio.create_subprocess_exec( + "git", "clone", url, str(DEFAULT_SESSIONS_WORKSPACE / session_id), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE ) + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300) + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "clone", url], stdout, stderr) + + logger.info(f"finished cloning to {DEFAULT_SESSIONS_WORKSPACE / session_id}") @cl.on_chat_start @@ -178,10 +167,15 @@ async def empty_current_session(): if os.path.exists(session_path): shutil.rmtree(session_path) +def remove_readonly(func, path, _): + """Clear the readonly bit and reattempt the removal""" + os.chmod(path, stat.S_IWRITE) + func(path) + @cl.on_app_shutdown async def empty_all_sessions(): if os.path.exists(DEFAULT_SESSIONS_WORKSPACE): - shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE) + shutil.rmtree(DEFAULT_SESSIONS_WORKSPACE, onexc=remove_readonly) @cl.action_callback("execute_steps") async def on_execute_steps(action :cl.Action): @@ -249,6 +243,11 @@ async def on_stop_steps(action :cl.Action): agent_tide_ui.current_step = None await task_list.remove() +@cl.action_callback("checkout_commit_push") +async def on_checkout_commit_push(action :cl.Action): + session_id = cl.user_session.get("session_id") + await commit_and_push_changes(DEFAULT_SESSIONS_WORKSPACE / session_id) + @cl.on_message async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None): agent_tide_ui = await loadAgentTideUi() @@ -256,7 +255,7 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None chat_history = cl.user_session.get("chat_history") if message.command: - command_prompt = agent_tide_ui.get_command_prompt(message.command) + command_prompt = await agent_tide_ui.get_command_prompt(message.command) if command_prompt: message.content = "\n\n---\n\n".join([command_prompt, message.content]) @@ -314,6 +313,12 @@ async def agent_loop(message: cl.Message, codeIdentifiers: Optional[list] = None tooltip="Next step", icon="fast-forward", payload={"msg_id": msg.id} + ), + cl.Action( + name="checkout_commit_push", + tooltip="A new branch will be created and the changes made so far will be commited and pushed to the upstream repository", + icon="circle-fading-arrow-up", + payload={"msg_id": msg.id} ) ] diff --git a/examples/hf_demo_space/git_utils.py b/examples/hf_demo_space/git_utils.py new file mode 100644 index 0000000..20967de --- /dev/null +++ b/examples/hf_demo_space/git_utils.py @@ -0,0 +1,117 @@ +import asyncio +from pathlib import Path +import re +import subprocess + + +GIT_URL_PATTERN = re.compile( + r'^(?:http|https|git|ssh)://' # Protocol + r'(?:\S+@)?' # Optional username + r'([^/]+)' # Domain + r'(?:[:/])([^/]+/[^/]+?)(?:\.git)?$' # Repo path +) + +async def validate_git_url(url) -> None: + """Validate the Git repository URL using git ls-remote.""" + + if not GIT_URL_PATTERN.match(url): + raise ValueError(f"Invalid Git repository URL format: {url}") + + try: + process = await asyncio.create_subprocess_exec( + "git", "ls-remote", url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "ls-remote", url], stdout, stderr) + + if not stdout.strip(): + raise ValueError(f"URL {url} points to an empty repository") + + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise ValueError(f"Timeout while validating URL {url}") + except subprocess.CalledProcessError as e: + raise ValueError(f"Invalid Git repository URL: {url}. Error: {e.stderr}") from e + +async def commit_and_push_changes(repo_path: Path, branch_name: str = None, commit_message: str = "Auto-commit: Save changes") -> None: + """Add all changes, commit with default message, and push to remote.""" + + repo_path_str = str(repo_path) + + try: + # Create new branch with Agent Tide + ULID name if not provided + if not branch_name: + import ulid + branch_name = f"agent-tide-{ulid.new()}" + + # Create and checkout new branch + process = await asyncio.create_subprocess_exec( + "git", "checkout", "-b", branch_name, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "checkout", "-b", branch_name], stdout, stderr) + + # Add all changes + process = await asyncio.create_subprocess_exec( + "git", "add", ".", + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "add", "."], stdout, stderr) + + # Commit changes + process = await asyncio.create_subprocess_exec( + "git", "commit", "-m", commit_message, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) + + if process.returncode != 0: + # Check if it's because there are no changes to commit + if "nothing to commit" in stderr or "nothing to commit" in stdout: + return # No changes to commit, exit gracefully + raise subprocess.CalledProcessError(process.returncode, ["git", "commit", "-m", commit_message], stdout, stderr) + + # Push to remote + process = await asyncio.create_subprocess_exec( + "git", "push", "origin", branch_name, + cwd=repo_path_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + text=True + ) + + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) + + if process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, ["git", "push", "origin", branch_name], stdout, stderr) + + except asyncio.TimeoutError: + process.kill() + await process.wait() + raise ValueError(f"Timeout during git operation in {repo_path}") + except subprocess.CalledProcessError as e: + raise ValueError(f"Git operation failed in {repo_path}. Error: {e.stderr}") from e