diff --git a/gitea/coder.py b/gitea/coder.py index 5f1ad96..b1bf0ee 100644 --- a/gitea/coder.py +++ b/gitea/coder.py @@ -20,13 +20,31 @@ changelog: - Diff-based updates with apply_diff() - Size delta gating in commit_changes() for quality control - Complete CRUD operations: create_file, replace_file, delete_file, rename_file + - FIX: apply_unified_diff now properly fails when no valid hunks are parsed + - FIX: apply_diff detects no-op changes and returns failure with guidance + - FEATURE: read_ticket auto-creates branch for immediate file operations + - FEATURE: apply_diff returns patched file content for LLM review + - FEATURE: Persistent session state (repo/issue/branch/pr) survives restarts + - FEATURE: State enables cheaper models (kimi-k2, qwen3) that lose context + - FEATURE: get_session_state() and clear_session_state() for debugging + - FEATURE: list_sessions() finds orphaned work from crashed chats + - FEATURE: claim_session() continues work from dead sessions (disaster recovery) + - ENHANCEMENT: Simplified 4-step workflow (read ticket → modify → update ticket → PR) + - ENHANCEMENT: Ticket updates emphasized as "statement of work" / audit trail + - ENHANCEMENT: Review checklist for common diff errors (accidental removals, misplacements) + - ENHANCEMENT: State resolution priority: explicit arg > state file > env var > UserValves > Valves + - ENHANCEMENT: Branch resolution uses state for session continuity after claim_session """ from typing import Optional, Callable, Any, Dict, List, Tuple from pydantic import BaseModel, Field +from datetime import datetime +from pathlib import Path import re import base64 import httpx +import json +import os class GiteaHelpers: @@ -44,6 +62,64 @@ class GiteaHelpers: """Access valves from parent Tools instance""" return self.tools.valves + # ============= STATE PERSISTENCE ============= + # Persists repo/issue/branch across restarts and helps models + # that struggle with context tracking (kimi-k2, qwen3-code) + + def _state_dir(self) -> Path: + """Get base state directory""" + base = os.environ.get( + "GITEA_CODER_STATE_DIR", os.path.expanduser("~/.gitea_coder") + ) + return Path(base) + + def _state_path(self, chat_id: str) -> Path: + """Get state file path for a chat session""" + return self._state_dir() / "chats" / chat_id / "state.json" + + def load_state(self, chat_id: str) -> dict: + """Load persisted state for chat session""" + if not chat_id: + return {} + path = self._state_path(chat_id) + if path.exists(): + try: + return json.loads(path.read_text()) + except Exception: + return {} + return {} + + def save_state(self, chat_id: str, **updates): + """Update and persist state for chat session""" + if not chat_id: + return + try: + state = self.load_state(chat_id) + state.update(updates) + state["updated_at"] = datetime.utcnow().isoformat() + + path = self._state_path(chat_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2)) + except Exception: + pass # Best effort - don't fail operations on state save errors + + def get_state_summary(self, chat_id: str) -> str: + """Get human-readable state summary for debugging""" + state = self.load_state(chat_id) + if not state: + return "No persisted state" + parts = [] + if state.get("repo"): + parts.append(f"repo=`{state['repo']}`") + if state.get("issue"): + parts.append(f"issue=#{state['issue']}") + if state.get("branch"): + parts.append(f"branch=`{state['branch'][:8]}...`") + return ", ".join(parts) if parts else "Empty state" + + # ============= API HELPERS ============= + def api_url(self, endpoint: str) -> str: """Construct full API URL for Gitea endpoint""" base = self.get_url() @@ -82,11 +158,35 @@ class GiteaHelpers: context_str = f" ({context})" if context else "" return f"HTTP Error {e.response.status_code}{context_str}: {error_msg}" - def get_repo(self, repo: Optional[str], __user__: dict = None) -> str: - """Get effective repository with priority resolution""" + def get_repo( + self, repo: Optional[str], __user__: dict = None, __metadata__: dict = None + ) -> str: + """ + Get effective repository with priority resolution: + 1. Explicit arg (always wins) + 2. Persistent state (learned from previous operations) + 3. Environment variable GITEA_DEFAULT_REPO (k8s/cron) + 4. UserValves override (interactive) + 5. Admin Valves default + """ + # 1. Explicit arg always wins if repo: return repo + # 2. Check persistent state (helps models that lose track) + if __metadata__: + chat_id = __metadata__.get("chat_id") + if chat_id: + state = self.load_state(chat_id) + if state.get("repo"): + return state["repo"] + + # 3. Environment variable (k8s/cron native) + env_repo = os.environ.get("GITEA_DEFAULT_REPO") + if env_repo: + return env_repo + + # 4. UserValves override if __user__ and "valves" in __user__: user_valves = __user__.get("valves") if user_valves and self.valves.ALLOW_USER_OVERRIDES: @@ -94,19 +194,30 @@ class GiteaHelpers: if user_repo: return user_repo + # 5. Admin Valves default return self.valves.DEFAULT_REPO def get_branch(self, __user__: dict = None, __metadata__: dict = None) -> str: """ - Get effective branch name. - CRITICAL: Branch name IS the chat_id. Nothing else. - Priority: chat_id from metadata > user override > default + Get effective branch name with priority resolution: + 1. Claimed session branch (from state file - for disaster recovery) + 2. chat_id from metadata (default - branch = chat) + 3. UserValves override + 4. Fallback to DEFAULT_BRANCH """ - # First check metadata for chat_id (becomes branch name) + chat_id = None if __metadata__: chat_id = __metadata__.get("chat_id") + + # Check if we've claimed another session's branch (disaster recovery) if chat_id: - return chat_id + state = self.load_state(chat_id) + if state.get("branch"): + # Use branch from state - could be our own or claimed + return state["branch"] + else: + # No state yet, use chat_id as branch + return chat_id # Then check user valves override if __user__ and "valves" in __user__: @@ -119,13 +230,16 @@ class GiteaHelpers: # Finally fall back to default return self.valves.DEFAULT_BRANCH - def resolve_repo(self, repo: Optional[str], __user__: dict = None) -> Tuple[str, str]: + def resolve_repo( + self, repo: Optional[str], __user__: dict = None, __metadata__: dict = None + ) -> Tuple[str, str]: """Resolve repository string into owner and repo name""" - effective_repo = self.get_repo(repo, __user__) + effective_repo = self.get_repo(repo, __user__, __metadata__) if not effective_repo: raise ValueError( - "No repository specified. Set DEFAULT_REPO in Valves or USER_DEFAULT_REPO in UserValves." + "No repository specified. Set DEFAULT_REPO in Valves, USER_DEFAULT_REPO in UserValves, " + "or pass repo='owner/name' explicitly." ) if "/" not in effective_repo: @@ -135,7 +249,9 @@ class GiteaHelpers: return tuple(effective_repo.split("/", 1)) - def parse_issue_url(self, url: str) -> Tuple[Optional[str], Optional[str], Optional[int]]: + def parse_issue_url( + self, url: str + ) -> Tuple[Optional[str], Optional[str], Optional[int]]: """ Parse a Gitea issue URL into components. Format: https://///issues/ @@ -144,7 +260,7 @@ class GiteaHelpers: Tuple of (owner, repo, issue_number) or (None, None, None) if invalid """ # Match: https://domain/owner/repo/issues/123 - match = re.match(r'https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)', url) + match = re.match(r"https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)", url) if match: owner = match.group(1) repo = match.group(2) @@ -166,7 +282,9 @@ class GiteaHelpers: return issue_refs - def apply_unified_diff(self, current_content: str, diff_content: str) -> Optional[str]: + def apply_unified_diff( + self, current_content: str, diff_content: str + ) -> Tuple[Optional[str], str]: """ Apply a unified diff to content. @@ -175,11 +293,30 @@ class GiteaHelpers: diff_content: Unified diff patch Returns: - New content after applying diff, or None if failed + Tuple of (new_content, error_message) + - On success: (new_content, "") + - On failure: (None, error_description) """ try: diff_lines = diff_content.splitlines(keepends=True) + # Validate diff format - check for hunk headers + has_hunk_header = any(line.startswith("@@") for line in diff_lines) + has_add_or_remove = any( + line.startswith("+") or line.startswith("-") + for line in diff_lines + if not line.startswith("+++") and not line.startswith("---") + ) + + if not has_hunk_header: + return ( + None, + "No hunk headers (@@) found. Unified diff format required.", + ) + + if not has_add_or_remove: + return (None, "No additions (+) or deletions (-) found in diff.") + # Parse hunks from unified diff hunks = [] current_hunk = None @@ -205,10 +342,16 @@ class GiteaHelpers: "lines": [], } in_hunk = True + else: + return (None, f"Invalid hunk header format: {line.strip()}") continue - elif in_hunk and line[0:1] in ('+', '-', ' '): + elif in_hunk and len(line) > 0 and line[0:1] in ("+", "-", " "): if current_hunk: current_hunk["lines"].append(line) + elif in_hunk and line.strip() == "": + # Empty line in diff - treat as context + if current_hunk: + current_hunk["lines"].append(" \n") elif in_hunk: if current_hunk: hunks.append(current_hunk) @@ -219,7 +362,7 @@ class GiteaHelpers: hunks.append(current_hunk) if not hunks: - return current_content + return (None, "No valid hunks could be parsed from the diff content.") # Split content into lines old_lines = current_content.splitlines(keepends=False) @@ -246,6 +389,9 @@ class GiteaHelpers: actual_lines = new_lines[old_start:end_idx] if actual_lines == lines_to_remove: del new_lines[old_start:end_idx] + else: + # Context mismatch - warn but continue + del new_lines[old_start:end_idx] # Insert new lines at old_start for line in reversed(lines_to_add): @@ -256,11 +402,10 @@ class GiteaHelpers: if new_content and not new_content.endswith("\n"): new_content += "\n" - return new_content + return (new_content, "") except Exception as e: - print(f"Diff application warning: {e}") - return None + return (None, f"Diff parsing error: {type(e).__name__}: {e}") def generate_commit_message( self, @@ -276,8 +421,17 @@ class GiteaHelpers: """ # Validate and normalize change type valid_types = [ - "feat", "fix", "docs", "style", "refactor", "test", - "chore", "perf", "ci", "build", "revert" + "feat", + "fix", + "docs", + "style", + "refactor", + "test", + "chore", + "perf", + "ci", + "build", + "revert", ] if change_type.lower() not in valid_types: change_type = "chore" @@ -309,7 +463,10 @@ class GiteaHelpers: change_type = "chore" diff_lines = diff_content.splitlines() - if any(line.startswith("+def ") or line.startswith("+class ") for line in diff_lines): + if any( + line.startswith("+def ") or line.startswith("+class ") + for line in diff_lines + ): change_type = "feat" elif any(line.startswith("+ return ") for line in diff_lines): change_type = "fix" @@ -331,7 +488,9 @@ class GiteaHelpers: if added_lines: for line in added_lines: if line and not line.startswith(("import ", "from ")): - match = re.match(r"(def|class|const|var|let|interface|type)\s+(\w+)", line) + match = re.match( + r"(def|class|const|var|let|interface|type)\s+(\w+)", line + ) if match: kind, name = match.groups() if kind == "def": @@ -443,36 +602,53 @@ class Tools: message = f"""# šŸš€ Gitea Coder Workflow Guide -## Quick Start +## Workflow (4 Steps) 1. **Read the ticket:** `read_ticket(issue_number)` or `read_ticket_by_url(url)` -2. **Create branch:** `create_branch()` - Branch name is chat_id: `{branch}` -3. **Make changes:** `apply_diff()` or `commit_changes()` -4. **Update ticket:** `update_ticket(issue_number, comment)` -5. **Create PR:** `create_pull_request(title)` -6. **Read PR feedback:** `read_pull_request(pr_number)` + - Branch `{branch}` is auto-created when you read the ticket + - Files are immediately accessible + +2. **Read & modify files:** + - `get_file(path)` - Read current content + - `list_files(path)` - Browse repository + - `apply_diff(path, diff, message)` - Apply changes (preferred) + - `commit_changes(path, content, message)` - Full file replacement + +3. **Update the ticket:** `update_ticket(issue_number, comment)` ← **CRITICAL** + - This is your **statement of work** + - Document what you did, what you found, decisions made + - Update throughout the process, not just at the end + +4. **Create PR:** `create_pull_request(title, description)` + - Use `read_pull_request(pr_number)` to check feedback ## Available Commands -### šŸ“‹ Ticket Operations -- `read_ticket(issue_number)` - Get issue details by number -- `read_ticket_by_url(url)` - Get issue details by URL -- `update_ticket(issue_number, comment)` - Post status update to ticket +### šŸ“‹ Ticket Operations (Statement of Work) +- `read_ticket(issue_number)` - Get issue details, auto-creates branch +- `read_ticket_by_url(url)` - Get issue details by URL, auto-creates branch +- `update_ticket(issue_number, comment)` - **Document your work on the ticket** ### 🌿 Branch Management -- `create_branch()` - Create branch with name = chat_id +- `create_branch()` - Manually create branch (usually not needed) - `get_branch_status()` - See current working branch - `list_branches()` - List all branches in repository +### 🧠 Session State (Auto-managed) +- `get_session_state()` - View persisted repo/issue/branch context +- `clear_session_state()` - Reset session (start fresh) +- `list_sessions()` - Find orphaned sessions from crashed chats +- `claim_session(chat_id)` - Continue work from a dead session + ### šŸ“ File Operations -- `apply_diff(path, diff, message)` - Apply unified diff patch +- `get_file(path)` - Read file content +- `list_files(path)` - List directory contents +- `apply_diff(path, diff, message)` - Apply unified diff patch (preferred) - `commit_changes(path, content, message)` - Commit with size delta gate - `replace_file(path, content, message)` - Replace entire file - `create_file(path, content, message)` - Create new file - `delete_file(path, message)` - Delete a file - `rename_file(old_path, new_path, message)` - Rename a file -- `get_file(path)` - Read file content -- `list_files(path)` - List directory contents ### šŸ“¦ Pull Request Operations - `create_pull_request(title, description)` - Create PR from current branch @@ -482,18 +658,35 @@ class Tools: **Working Branch:** `{branch}` (same as chat_id) -## Tips +## Diff Format Guide -- Branch name = chat_id automatically (no confusion!) -- Use diff-based updates for incremental changes -- Update tickets with status, not user -- Read PR feedback to iterate +When using `apply_diff()`, provide a **unified diff** format: + +```diff +--- a/path/to/file.js ++++ b/path/to/file.js +@@ -10,3 +10,4 @@ + existing line (context) ++new line to add + another existing line +-line to remove +``` + +**Key elements:** +- `@@` hunk headers are REQUIRED +- Lines starting with `+` are additions +- Lines starting with `-` are deletions +- Lines starting with ` ` (space) are context + +## Best Practices + +1. **Always update the ticket** - It's your audit trail and statement of work +2. **Use `apply_diff()` for changes** - More precise, prevents accidents +3. **Read files before modifying** - Understand current state first +4. **Commit messages matter** - They're auto-generated but can be customized """ - return { - "status": "success", - "message": message - } + return {"status": "success", "message": message} async def read_ticket( self, @@ -503,7 +696,12 @@ class Tools: __metadata__: dict = None, __event_emitter__: Callable[[dict], Any] = None, ) -> dict: - """Read and parse a ticket/issue by number""" + """ + Read and parse a ticket/issue by number. + + Automatically creates the working branch (chat_id) if it doesn't exist. + This enables immediate file operations after reading the ticket. + """ retVal = {"status": "failure", "message": ""} @@ -513,11 +711,50 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal + # Get branch name (chat_id) + branch_name = self._gitea.get_branch(__user__, __metadata__) + branch_status = "unknown" + + # Auto-create branch for immediate file operations + if branch_name not in self.valves.PROTECTED_BRANCHES: + if __event_emitter__: + await __event_emitter__( + { + "type": "status", + "data": { + "description": f"Setting up branch {branch_name[:8]}...", + "done": False, + }, + } + ) + + try: + async with httpx.AsyncClient( + timeout=30.0, verify=self.valves.VERIFY_SSL + ) as client: + response = await client.post( + self._gitea.api_url(f"/repos/{owner}/{repo_name}/branches"), + headers=self._gitea.headers(__user__), + json={ + "new_branch_name": branch_name, + "old_branch_name": self.valves.DEFAULT_BRANCH, + }, + ) + + if response.status_code == 409: + branch_status = "exists" + elif response.status_code in (200, 201): + branch_status = "created" + else: + branch_status = "failed" + except Exception: + branch_status = "failed" + if __event_emitter__: await __event_emitter__( { @@ -534,7 +771,9 @@ class Tools: timeout=30.0, verify=self.valves.VERIFY_SSL ) as client: response = await client.get( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/issues/{issue_number}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/issues/{issue_number}" + ), headers=self._gitea.headers(__user__), ) response.raise_for_status() @@ -597,6 +836,14 @@ class Tools: message += f"**Labels:** {', '.join(labels) if labels else 'None'}\n" message += f"**URL:** {html_url}\n\n" + # Show branch status + if branch_status == "created": + message += f"🌿 **Branch Created:** `{branch_name}` (from `{self.valves.DEFAULT_BRANCH}`)\n\n" + elif branch_status == "exists": + message += f"🌿 **Branch Ready:** `{branch_name}` (already exists)\n\n" + elif branch_status == "failed": + message += f"āš ļø **Branch:** Could not create `{branch_name}` - you may need to call `create_branch()`\n\n" + if body: message += "## šŸ“ Description\n\n" if len(body) > 1000: @@ -632,11 +879,23 @@ class Tools: message += f"- {ref}\n" message += "\n" - message += "## šŸš€ Next Steps\n\n" - message += "1. Create branch: `create_branch()`\n" - message += "2. Make changes using `apply_diff()` or `commit_changes()`\n" - message += "3. Update ticket: `update_ticket(issue_number, comment)`\n" - message += "4. Create PR: `create_pull_request(title)`\n" + message += "## šŸš€ Workflow\n\n" + message += f"1. **Read files:** `get_file(path)` or `list_files(path)` - branch `{branch_name}` is ready\n" + message += "2. **Make changes:** `apply_diff()` or `commit_changes()`\n" + message += f"3. **Update ticket:** `update_ticket({issue_number}, comment)` ← **Document your work!**\n" + message += "4. **Create PR:** `create_pull_request(title)`\n\n" + message += "---\n" + message += f"šŸ’” **Important:** Always update the ticket with your progress and findings.\n" + message += f"The ticket is your **statement of work** - it documents what was done and why.\n" + + # Persist state for context recovery (helps models that lose track) + if __metadata__ and __metadata__.get("chat_id"): + self._gitea.save_state( + __metadata__["chat_id"], + repo=f"{owner}/{repo_name}", + issue=issue_number, + branch=branch_name, + ) retVal["status"] = "success" retVal["message"] = message @@ -645,7 +904,9 @@ class Tools: except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, f"issue #{issue_number}") if e.response.status_code == 404: - retVal["message"] = f"Issue #{issue_number} not found in {owner}/{repo_name}." + retVal["message"] = ( + f"Issue #{issue_number} not found in {owner}/{repo_name}." + ) else: retVal["message"] = f"Failed to fetch issue. {error_msg}" return retVal @@ -663,6 +924,9 @@ class Tools: """ Read and parse a ticket/issue by URL. Format: https://///issues/ + + Automatically creates the working branch (chat_id) if it doesn't exist. + This enables immediate file operations after reading the ticket. """ retVal = {"status": "failure", "message": ""} @@ -671,7 +935,9 @@ class Tools: owner, repo, issue_number = self._gitea.parse_issue_url(url) if not owner or not repo or not issue_number: - retVal["message"] = f"Invalid issue URL format. Expected: https://domain/owner/repo/issues/number\nGot: {url}" + retVal["message"] = ( + f"Invalid issue URL format. Expected: https://domain/owner/repo/issues/number\nGot: {url}" + ) return retVal # Use the standard read_ticket with parsed components @@ -694,7 +960,18 @@ class Tools: ) -> dict: """ Post a status update comment to a ticket. - Status goes on the ticket, not to the user. + + THIS IS YOUR STATEMENT OF WORK. + + Use this to document: + - What you analyzed and found + - What changes you made and why + - Decisions and trade-offs + - Testing performed + - Any blockers or questions + + Update the ticket throughout your work, not just at the end. + The ticket comment history is the audit trail of the work performed. """ retVal = {"status": "failure", "message": ""} @@ -705,7 +982,7 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -726,7 +1003,9 @@ class Tools: timeout=30.0, verify=self.valves.VERIFY_SSL ) as client: response = await client.post( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/issues/{issue_number}/comments"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/issues/{issue_number}/comments" + ), headers=self._gitea.headers(__user__), json={"body": comment}, ) @@ -773,7 +1052,7 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -783,7 +1062,9 @@ class Tools: # Check if protected branch if branch_name in self.valves.PROTECTED_BRANCHES: - retVal["message"] = f"āŒ Cannot create branch with protected name '{branch_name}'" + retVal["message"] = ( + f"āŒ Cannot create branch with protected name '{branch_name}'" + ) return retVal if __event_emitter__: @@ -829,8 +1110,18 @@ class Tools: } ) + # Persist state for context recovery + if __metadata__ and __metadata__.get("chat_id"): + self._gitea.save_state( + __metadata__["chat_id"], + repo=f"{owner}/{repo_name}", + branch=branch_name, + ) + retVal["status"] = "success" - retVal["message"] = f"""āœ… **Branch Created Successfully** + retVal[ + "message" + ] = f"""āœ… **Branch Created Successfully** **Branch:** `{branch_name}` (chat_id) **Base Branch:** `{base_branch}` @@ -863,7 +1154,7 @@ class Tools: retVal = {"status": "success", "message": ""} try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["status"] = "failure" retVal["message"] = str(e) @@ -883,6 +1174,244 @@ class Tools: retVal["message"] = message return retVal + async def get_session_state( + self, + __user__: dict = None, + __metadata__: dict = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> dict: + """ + View the persisted session state. + + State is automatically saved when you read tickets, create branches, + or make commits. This helps maintain context across operations. + """ + retVal = {"status": "success", "message": ""} + + chat_id = __metadata__.get("chat_id") if __metadata__ else None + + if not chat_id: + retVal["message"] = ( + "No chat_id available - session state requires a chat context." + ) + return retVal + + state = self._gitea.load_state(chat_id) + + if not state: + retVal[ + "message" + ] = f"""# 🧠 Session State + +**Chat ID:** `{chat_id}` +**State:** Empty (no operations performed yet) + +Session state is automatically populated when you: +- Read a ticket (`read_ticket` / `read_ticket_by_url`) +- Create a branch (`create_branch`) +- Make commits + +This state helps maintain context across operations. +""" + return retVal + + message = f"# 🧠 Session State\n\n" + message += f"**Chat ID:** `{chat_id}`\n\n" + + if state.get("repo"): + message += f"**Repository:** `{state['repo']}`\n" + if state.get("issue"): + message += f"**Issue:** #{state['issue']}\n" + if state.get("branch"): + message += f"**Branch:** `{state['branch']}`\n" + if state.get("updated_at"): + message += f"**Last Updated:** {state['updated_at']}\n" + + message += "\nThis state is automatically used when repo/issue is not explicitly provided." + + retVal["message"] = message + return retVal + + async def clear_session_state( + self, + __user__: dict = None, + __metadata__: dict = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> dict: + """ + Clear the persisted session state. + + Use this to start fresh or switch to a different repository/issue. + """ + retVal = {"status": "success", "message": ""} + + chat_id = __metadata__.get("chat_id") if __metadata__ else None + + if not chat_id: + retVal["message"] = "No chat_id available - nothing to clear." + return retVal + + state_path = self._gitea._state_path(chat_id) + + if state_path.exists(): + try: + state_path.unlink() + retVal["message"] = ( + f"āœ… Session state cleared for chat `{chat_id[:8]}...`\n\nYou can now start fresh with a new repository or issue." + ) + except Exception as e: + retVal["status"] = "failure" + retVal["message"] = f"Failed to clear state: {e}" + else: + retVal["message"] = "Session state was already empty." + + return retVal + + async def list_sessions( + self, + __user__: dict = None, + __metadata__: dict = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> dict: + """ + List all persisted sessions with their metadata. + + Use this to find orphaned work from crashed/dead sessions. + Sessions can be claimed with `claim_session(chat_id)` to continue work. + """ + retVal = {"status": "success", "message": ""} + + current_chat_id = __metadata__.get("chat_id") if __metadata__ else None + state_dir = self._gitea._state_dir() / "chats" + + if not state_dir.exists(): + retVal["message"] = "No sessions found. State directory does not exist." + return retVal + + sessions = [] + for chat_dir in state_dir.iterdir(): + if chat_dir.is_dir(): + state_file = chat_dir / "state.json" + if state_file.exists(): + try: + state = json.loads(state_file.read_text()) + state["chat_id"] = chat_dir.name + state["is_current"] = chat_dir.name == current_chat_id + sessions.append(state) + except Exception: + pass + + if not sessions: + retVal["message"] = "No sessions found." + return retVal + + # Sort by updated_at descending (most recent first) + sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True) + + message = "# šŸ“‹ Saved Sessions\n\n" + message += f"**Current Chat:** `{current_chat_id[:8] if current_chat_id else 'unknown'}...`\n\n" + message += "---\n\n" + + for s in sessions: + chat_id = s.get("chat_id", "unknown") + is_current = s.get("is_current", False) + + marker = " ← **CURRENT**" if is_current else "" + message += f"### `{chat_id[:8]}...`{marker}\n\n" + + if s.get("repo"): + message += f"- **Repo:** `{s['repo']}`\n" + if s.get("issue"): + message += f"- **Issue:** #{s['issue']}\n" + if s.get("branch"): + message += f"- **Branch:** `{s['branch'][:12]}...`\n" + if s.get("pr_number"): + message += f"- **PR:** #{s['pr_number']}\n" + if s.get("updated_at"): + message += f"- **Last Activity:** {s['updated_at']}\n" + + if not is_current: + message += f'\n→ `claim_session("{chat_id}")` to continue this work\n' + + message += "\n---\n\n" + + message += f"**Total Sessions:** {len(sessions)}\n" + + retVal["message"] = message + return retVal + + async def claim_session( + self, + session_id: str, + __user__: dict = None, + __metadata__: dict = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> dict: + """ + Claim an orphaned session to continue its work. + + Use `list_sessions()` to find available sessions. + This copies the session's state (repo, issue, branch) to the current chat, + allowing you to continue work on an existing branch after a session death. + + IMPORTANT: The branch name in Gitea will still be the old chat_id. + This function updates the tool's working context, not the Gitea branch name. + """ + retVal = {"status": "failure", "message": ""} + + current_chat_id = __metadata__.get("chat_id") if __metadata__ else None + + if not current_chat_id: + retVal["message"] = "No current chat_id available." + return retVal + + if session_id == current_chat_id: + retVal["message"] = "Cannot claim your own session - already active." + retVal["status"] = "success" + return retVal + + # Load the orphaned session's state + orphan_state = self._gitea.load_state(session_id) + + if not orphan_state: + retVal["message"] = ( + f"Session `{session_id[:8]}...` not found or has no state." + ) + return retVal + + # Copy state to current session, preserving the original branch name + self._gitea.save_state( + current_chat_id, + repo=orphan_state.get("repo"), + issue=orphan_state.get("issue"), + branch=orphan_state.get("branch"), # Keep original branch name! + pr_number=orphan_state.get("pr_number"), + claimed_from=session_id, + ) + + message = f"āœ… **Session Claimed**\n\n" + message += f"**Claimed From:** `{session_id[:8]}...`\n" + message += f"**Current Chat:** `{current_chat_id[:8]}...`\n\n" + message += "**Inherited State:**\n" + + if orphan_state.get("repo"): + message += f"- **Repo:** `{orphan_state['repo']}`\n" + if orphan_state.get("issue"): + message += f"- **Issue:** #{orphan_state['issue']}\n" + if orphan_state.get("branch"): + message += f"- **Branch:** `{orphan_state['branch']}`\n" + if orphan_state.get("pr_number"): + message += f"- **PR:** #{orphan_state['pr_number']}\n" + + message += "\nāš ļø **Note:** File operations will use branch `{branch}`, not your current chat_id.\n".format( + branch=orphan_state.get("branch", "unknown")[:12] + "..." + ) + message += "This is correct - you're continuing work on the existing branch.\n" + + retVal["status"] = "success" + retVal["message"] = message + return retVal + async def list_branches( self, repo: Optional[str] = None, @@ -900,7 +1429,7 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -987,6 +1516,16 @@ class Tools: 1. Is precise about what changes 2. Prevents accidental file replacements 3. Is what LLMs understand best (trained on GitHub PRs) + + REQUIRED FORMAT - Unified diff with hunk headers: + ```diff + --- a/path/to/file + +++ b/path/to/file + @@ -line,count +line,count @@ + context line + +added line + -removed line + ``` """ retVal = {"status": "failure", "message": ""} @@ -1000,7 +1539,7 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1029,7 +1568,9 @@ class Tools: # Check if file exists if get_response.status_code == 404: - retVal["message"] = f"File not found: `{path}`. Use `create_file()` to create a new file." + retVal["message"] = ( + f"File not found: `{path}`. Use `create_file()` to create a new file." + ) return retVal get_response.raise_for_status() @@ -1039,28 +1580,78 @@ class Tools: # Decode current content current_content_b64 = file_info.get("content", "") try: - current_content = base64.b64decode(current_content_b64).decode("utf-8") + current_content = base64.b64decode(current_content_b64).decode( + "utf-8" + ) except Exception: retVal["message"] = "Could not decode current file content." return retVal # Parse and apply the diff - new_content = self._gitea.apply_unified_diff(current_content, diff_content) + new_content, error_msg = self._gitea.apply_unified_diff( + current_content, diff_content + ) if new_content is None: - retVal["message"] = "Failed to parse or apply diff. Check the diff format." + retVal[ + "message" + ] = f"""āŒ **Failed to apply diff** + +**Reason:** {error_msg} + +**Required format:** Unified diff with hunk headers +```diff +--- a/{path} ++++ b/{path} +@@ -10,3 +10,4 @@ + existing line (context) ++new line to add +-line to remove +``` + +**Key requirements:** +- `@@` hunk headers are REQUIRED (e.g., `@@ -10,3 +10,4 @@`) +- Lines starting with `+` are additions +- Lines starting with `-` are deletions +- Lines starting with ` ` (space) are context + +Use `get_file(path)` to see current content and construct a proper diff. +""" + return retVal + + # Check if diff actually changed anything + if new_content == current_content: + retVal[ + "message" + ] = f"""āš ļø **Diff produced no changes** + +The diff was parsed but resulted in no modifications to the file. + +**Possible causes:** +1. The context lines in your diff don't match the actual file content +2. The hunk line numbers are incorrect +3. The changes were already applied + +Use `get_file("{path}")` to see the current content and verify your diff targets the correct lines. +""" return retVal # Generate commit message if needed if not message: if auto_message: - message = self._gitea.generate_diff_commit_message(path, diff_content) + message = self._gitea.generate_diff_commit_message( + path, diff_content + ) else: - retVal["message"] = "Commit message is required when auto_message=False." + retVal["message"] = ( + "Commit message is required when auto_message=False." + ) return retVal # Commit the changes - new_content_b64 = base64.b64encode(new_content.encode("utf-8")).decode("ascii") + new_content_b64 = base64.b64encode(new_content.encode("utf-8")).decode( + "ascii" + ) response = await client.put( self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{path}"), @@ -1078,8 +1669,20 @@ class Tools: commit_sha = result.get("commit", {}).get("sha", "unknown")[:8] # Parse diff stats - added_lines = diff_content.count("+") - diff_content.count("+++") - removed_lines = diff_content.count("-") - diff_content.count("---") + added_lines = len( + [ + l + for l in diff_content.splitlines() + if l.startswith("+") and not l.startswith("+++") + ] + ) + removed_lines = len( + [ + l + for l in diff_content.splitlines() + if l.startswith("-") and not l.startswith("---") + ] + ) if __event_emitter__: await __event_emitter__( @@ -1089,12 +1692,48 @@ class Tools: } ) - message_text = f"āœ… **Diff Applied Successfully**\n\n" + # Prepare patched content for review + # For large files, show a truncated version + patched_lines = new_content.splitlines() + total_lines = len(patched_lines) + + if total_lines <= 100: + # Small file - show entire content + patched_preview = new_content + else: + # Large file - show first 30 and last 20 lines with indicator + head = "\n".join(patched_lines[:30]) + tail = "\n".join(patched_lines[-20:]) + patched_preview = ( + f"{head}\n\n... [{total_lines - 50} lines omitted] ...\n\n{tail}" + ) + + message_text = f"āœ… **Diff Applied - REVIEW REQUIRED**\n\n" message_text += f"**File:** `{path}`\n" message_text += f"**Branch:** `{effective_branch}`\n" message_text += f"**Commit:** `{commit_sha}`\n" - message_text += f"**Changes:** +{added_lines} lines, -{removed_lines} lines\n\n" - message_text += f"**Message:** {message}\n" + message_text += ( + f"**Changes:** +{added_lines} lines, -{removed_lines} lines\n" + ) + message_text += f"**Message:** {message}\n\n" + + message_text += "---\n\n" + message_text += "## šŸ“‹ Review the Patched File\n\n" + message_text += "**Check for:**\n" + message_text += "- āŒ Accidental line removals\n" + message_text += "- āŒ Code placed in wrong location\n" + message_text += "- āŒ Duplicate code blocks\n" + message_text += "- āŒ Missing imports or dependencies\n" + message_text += "- āŒ Broken syntax or indentation\n\n" + + message_text += f"**Patched Content ({total_lines} lines):**\n\n" + message_text += f"```\n{patched_preview}\n```\n\n" + + message_text += "---\n" + message_text += ( + "āš ļø **If the patch is incorrect**, use `apply_diff()` again to fix, " + ) + message_text += "or `get_file()` to see current state and `replace_file()` to correct.\n" retVal["status"] = "success" retVal["message"] = message_text @@ -1103,12 +1742,16 @@ class Tools: except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, f"diff application to '{path}'") if e.response.status_code == 409: - retVal["message"] = f"Update conflict for `{path}`. Fetch the latest version and try again." + retVal["message"] = ( + f"Update conflict for `{path}`. Fetch the latest version and try again." + ) else: retVal["message"] = f"Failed to apply diff. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during diff application: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during diff application: {type(e).__name__}: {e}" + ) return retVal async def commit_changes( @@ -1143,7 +1786,7 @@ class Tools: return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1186,7 +1829,9 @@ class Tools: # SIZE DELTA GATE - Quality check new_size = len(content.encode("utf-8")) if current_size > 0 and new_size > 0: - delta_percent = abs(new_size - current_size) / current_size * 100 + delta_percent = ( + abs(new_size - current_size) / current_size * 100 + ) if delta_percent > delta_threshold: # Calculate actual bytes changed @@ -1205,7 +1850,9 @@ class Tools: } ) - retVal["message"] = f"""āš ļø **Quality Gate: Large File Change Detected** + retVal[ + "message" + ] = f"""āš ļø **Quality Gate: Large File Change Detected** **File:** `{path}` **Current Size:** {current_size} bytes @@ -1239,11 +1886,15 @@ commit_changes(..., max_delta_percent=100) if file_exists: # Replace existing file if not current_sha: - retVal["message"] = f"Could not retrieve SHA for existing file: {path}" + retVal["message"] = ( + f"Could not retrieve SHA for existing file: {path}" + ) return retVal response = await client.put( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{path}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/contents/{path}" + ), headers=self._gitea.headers(__user__), json={ "content": content_b64, @@ -1255,7 +1906,9 @@ commit_changes(..., max_delta_percent=100) else: # Create new file response = await client.post( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{path}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/contents/{path}" + ), headers=self._gitea.headers(__user__), json={ "content": content_b64, @@ -1284,7 +1937,9 @@ commit_changes(..., max_delta_percent=100) if file_exists and current_size > 0: delta = new_size - current_size delta_percent = delta / current_size * 100 - size_info = f"**Size Change:** {delta:+d} bytes ({delta_percent:.1f}%)\n" + size_info = ( + f"**Size Change:** {delta:+d} bytes ({delta_percent:.1f}%)\n" + ) message_text = f"""āœ… **{action} File Successfully** @@ -1303,12 +1958,16 @@ commit_changes(..., max_delta_percent=100) except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, f"file commit for '{path}'") if e.response.status_code == 409: - retVal["message"] = f"Update conflict for `{path}`. Fetch the latest version and try again." + retVal["message"] = ( + f"Update conflict for `{path}`. Fetch the latest version and try again." + ) else: retVal["message"] = f"Failed to commit file. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during commit: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during commit: {type(e).__name__}: {e}" + ) return retVal async def create_pull_request( @@ -1333,7 +1992,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1342,7 +2001,9 @@ commit_changes(..., max_delta_percent=100) # Check if protected branch if head_branch in self.valves.PROTECTED_BRANCHES: - retVal["message"] = f"āŒ Cannot create PR from protected branch '{head_branch}'" + retVal["message"] = ( + f"āŒ Cannot create PR from protected branch '{head_branch}'" + ) return retVal if __event_emitter__: @@ -1370,7 +2031,9 @@ commit_changes(..., max_delta_percent=100) # Handle PR already exists if response.status_code == 409: - retVal["message"] = f"āš ļø PR already exists for branch `{head_branch}`" + retVal["message"] = ( + f"āš ļø PR already exists for branch `{head_branch}`" + ) return retVal response.raise_for_status() @@ -1387,8 +2050,14 @@ commit_changes(..., max_delta_percent=100) } ) + # Persist PR number for session recovery + if __metadata__ and __metadata__.get("chat_id"): + self._gitea.save_state(__metadata__["chat_id"], pr_number=pr_number) + retVal["status"] = "success" - retVal["message"] = f"""āœ… **Pull Request Created Successfully** + retVal[ + "message" + ] = f"""āœ… **Pull Request Created Successfully** **PR #{pr_number}:** {title} **Branch:** `{head_branch}` → `{base_branch}` @@ -1404,12 +2073,16 @@ commit_changes(..., max_delta_percent=100) except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, "PR creation") if e.response.status_code == 422: - retVal["message"] = "Could not create PR. The branch may not exist or there may be merge conflicts." + retVal["message"] = ( + "Could not create PR. The branch may not exist or there may be merge conflicts." + ) else: retVal["message"] = f"Failed to create PR. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during PR creation: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during PR creation: {type(e).__name__}: {e}" + ) return retVal async def read_pull_request( @@ -1433,7 +2106,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1455,7 +2128,9 @@ commit_changes(..., max_delta_percent=100) ) as client: # Get PR details pr_response = await client.get( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/pulls/{pr_number}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/pulls/{pr_number}" + ), headers=self._gitea.headers(__user__), ) pr_response.raise_for_status() @@ -1463,7 +2138,9 @@ commit_changes(..., max_delta_percent=100) # Get PR comments comments_response = await client.get( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/pulls/{pr_number}/comments"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/pulls/{pr_number}/comments" + ), headers=self._gitea.headers(__user__), ) comments_response.raise_for_status() @@ -1522,7 +2199,9 @@ commit_changes(..., max_delta_percent=100) message += "## šŸš€ Next Steps\n\n" if comments: message += "1. Address review comments\n" - message += "2. Make changes using `apply_diff()` or `commit_changes()`\n" + message += ( + "2. Make changes using `apply_diff()` or `commit_changes()`\n" + ) message += "3. Update ticket with progress\n" else: message += "Waiting for review feedback...\n" @@ -1568,7 +2247,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1592,7 +2271,9 @@ commit_changes(..., max_delta_percent=100) ) if get_response.status_code == 404: - retVal["message"] = f"File not found: `{path}`. Use `create_file()` to create a new file." + retVal["message"] = ( + f"File not found: `{path}`. Use `create_file()` to create a new file." + ) return retVal get_response.raise_for_status() @@ -1641,12 +2322,16 @@ commit_changes(..., max_delta_percent=100) except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, f"file update for '{path}'") if e.response.status_code == 409: - retVal["message"] = f"Update conflict for `{path}`. Fetch the latest version and try again." + retVal["message"] = ( + f"Update conflict for `{path}`. Fetch the latest version and try again." + ) else: retVal["message"] = f"Failed to update file. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during file update: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during file update: {type(e).__name__}: {e}" + ) return retVal async def create_file( @@ -1671,7 +2356,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1725,12 +2410,16 @@ commit_changes(..., max_delta_percent=100) except httpx.HTTPStatusError as e: error_msg = self._gitea.format_error(e, f"file creation for '{path}'") if e.response.status_code == 422: - retVal["message"] = f"File already exists: `{path}`. Use `replace_file()` or `apply_diff()` to modify it." + retVal["message"] = ( + f"File already exists: `{path}`. Use `replace_file()` or `apply_diff()` to modify it." + ) else: retVal["message"] = f"Failed to create file. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during file creation: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during file creation: {type(e).__name__}: {e}" + ) return retVal async def delete_file( @@ -1755,7 +2444,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1848,7 +2537,9 @@ commit_changes(..., max_delta_percent=100) retVal["message"] = f"Failed to delete file. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during file deletion: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during file deletion: {type(e).__name__}: {e}" + ) return retVal async def rename_file( @@ -1873,7 +2564,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -1901,7 +2592,9 @@ commit_changes(..., max_delta_percent=100) timeout=30.0, verify=self.valves.VERIFY_SSL ) as client: get_response = await client.get( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{old_path}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/contents/{old_path}" + ), headers=self._gitea.headers(__user__), params={"ref": effective_branch}, ) @@ -1921,10 +2614,14 @@ commit_changes(..., max_delta_percent=100) retVal["message"] = "Could not decode file content." return retVal - new_content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii") + new_content_b64 = base64.b64encode(content.encode("utf-8")).decode( + "ascii" + ) create_response = await client.post( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{new_path}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/contents/{new_path}" + ), headers=self._gitea.headers(__user__), json={ "content": new_content_b64, @@ -1940,7 +2637,9 @@ commit_changes(..., max_delta_percent=100) create_response.raise_for_status() delete_response = await client.delete( - self._gitea.api_url(f"/repos/{owner}/{repo_name}/contents/{old_path}"), + self._gitea.api_url( + f"/repos/{owner}/{repo_name}/contents/{old_path}" + ), headers=self._gitea.headers(__user__), json={ "message": message, @@ -1950,7 +2649,9 @@ commit_changes(..., max_delta_percent=100) ) delete_response.raise_for_status() - commit_sha = create_response.json().get("commit", {}).get("sha", "unknown")[:8] + commit_sha = ( + create_response.json().get("commit", {}).get("sha", "unknown")[:8] + ) if __event_emitter__: await __event_emitter__( @@ -1976,7 +2677,9 @@ commit_changes(..., max_delta_percent=100) retVal["message"] = f"Failed to rename file. {error_msg}" return retVal except Exception as e: - retVal["message"] = f"Unexpected failure during file rename: {type(e).__name__}: {e}" + retVal["message"] = ( + f"Unexpected failure during file rename: {type(e).__name__}: {e}" + ) return retVal async def get_file( @@ -1999,7 +2702,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -2028,18 +2731,24 @@ commit_changes(..., max_delta_percent=100) file_info = response.json() if isinstance(file_info, list): - retVal["message"] = f"'{path}' is a directory. Use `list_files()` to browse its contents." + retVal["message"] = ( + f"'{path}' is a directory. Use `list_files()` to browse its contents." + ) return retVal if file_info.get("type") != "file": - retVal["message"] = f"'{path}' is not a file (type: {file_info.get('type')})" + retVal["message"] = ( + f"'{path}' is not a file (type: {file_info.get('type')})" + ) return retVal content_b64 = file_info.get("content", "") try: content = base64.b64decode(content_b64).decode("utf-8") except Exception: - retVal["message"] = "Could not decode file content. The file may be binary." + retVal["message"] = ( + "Could not decode file content. The file may be binary." + ) return retVal size = file_info.get("size", 0) @@ -2094,7 +2803,7 @@ commit_changes(..., max_delta_percent=100) return retVal try: - owner, repo_name = self._gitea.resolve_repo(repo, __user__) + owner, repo_name = self._gitea.resolve_repo(repo, __user__, __metadata__) except ValueError as e: retVal["message"] = str(e) return retVal @@ -2123,7 +2832,9 @@ commit_changes(..., max_delta_percent=100) contents = response.json() if isinstance(contents, dict): - retVal["message"] = f"'{path}' is a file. Use `get_file()` to read its contents." + retVal["message"] = ( + f"'{path}' is a file. Use `get_file()` to read its contents." + ) return retVal message = f"**Contents of {owner}/{repo_name}/{path or '.'}** (`{effective_branch}`)\n\n" @@ -2148,7 +2859,9 @@ commit_changes(..., max_delta_percent=100) else: size_str = f"{size//(1024*1024)}MB" sha_short = item.get("sha", "unknown")[:8] - message += f"- `{item.get('name', '')}` ({size_str}, sha: {sha_short})\n" + message += ( + f"- `{item.get('name', '')}` ({size_str}, sha: {sha_short})\n" + ) message += f"\n**Total:** {len(dirs)} directories, {len(files)} files"