feat(github_tool): add commit_file_patch to support partial line-based edits without uploading entire file content from the caller

- New function commit_file_patch(file_path, commit_message, edits, base_sha=None)
- Applies line-range edits on the current file content and commits the result
- Preserves original newline style and supports insert/replace/delete via ranges
- Prevents committing directly to main and detects stale base via optional base_sha

This enables the agent to send only the minimal edits rather than entire files.
This commit is contained in:
2025-08-09 18:26:07 -05:00
parent 9ce0c9943b
commit 21b0fff6c4
+155 -2
View File
@@ -134,6 +134,36 @@ class GitHubTool(BaseTool):
},
"_tags": ["write"]
},
{
"type": "function",
"function": {
"name": "commit_file_patch",
"description": "Apply partial line-based edits to a file and commit the result (without requiring the caller to upload the entire file).",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Path to the file in the repository"},
"commit_message": {"type": "string", "description": "Commit message"},
"edits": {
"type": "array",
"description": "List of line-based edits. Each edit replaces lines [start_line..end_line] (inclusive) with 'replacement'. If end_line < start_line, the 'replacement' is inserted before start_line.",
"items": {
"type": "object",
"properties": {
"start_line": {"type": "integer", "description": "1-based start line"},
"end_line": {"type": "integer", "description": "1-based end line (inclusive), or set less than start_line for insertion"},
"replacement": {"type": "string", "description": "Replacement text for the specified range (can be multi-line)"}
},
"required": ["start_line", "end_line", "replacement"]
}
},
"base_sha": {"type": "string", "description": "Optional expected current blob SHA for the file; if provided and does not match, the operation aborts to prevent overwriting newer changes."}
},
"required": ["file_path", "commit_message", "edits"]
}
},
"_tags": ["write"]
},
{
"type": "function",
"function": {
@@ -688,7 +718,7 @@ class GitHubTool(BaseTool):
return error_message
def _commit_file(self, file_path, content, commit_message):
content.strip("'''")
content = content.strip("'''")
self.logger.info(f"Committing file: {file_path} to branch: {self.current_branch} with message: '{commit_message}'")
if self.current_branch == "main":
error_message = "Action directly to main branch is not allowed. Please create and switch to a new branch first."
@@ -727,8 +757,131 @@ class GitHubTool(BaseTool):
self.logger.error(error_message)
return error_message
def _commit_file_patch(self, file_path, edits, commit_message, base_sha=None):
self.logger.info(f"Committing partial edits to file: {file_path} on branch: {self.current_branch}")
if self.current_branch == "main":
error_message = "Action directly to main branch is not allowed. Please create and switch to a new branch first."
self.logger.warning(error_message)
return error_message
# Fetch current file content and sha
url = f"{self.base_url}/repos/{self._repo}/contents/{file_path}"
get_response = self.session.get(url, params={"ref": self.current_branch})
if get_response.status_code == 404:
error_message = f"File '{file_path}' not found on branch '{self.current_branch}'. Cannot apply partial edits to a non-existent file."
self.logger.error(error_message)
return error_message
if get_response.status_code != 200:
error_message = f"Error reading file '{file_path}' for patching: {get_response.status_code} - {get_response.text}"
self.logger.error(error_message)
return error_message
current_sha = get_response.json()["sha"]
if base_sha and base_sha != current_sha:
error_message = (
f"Abort: base_sha mismatch for '{file_path}'. Expected {base_sha}, current {current_sha}. "
"Please refresh the file content and re-apply your edits."
)
self.logger.warning(error_message)
return error_message
content_b64 = get_response.json()["content"]
decoded_content = base64.b64decode(content_b64).decode('utf-8')
# Apply edits
try:
new_content = self._apply_line_edits(decoded_content, edits)
except Exception as e:
self.logger.error(f"Failed to apply edits to '{file_path}': {e}", exc_info=True)
return f"Error applying edits: {str(e)}"
if new_content == decoded_content:
msg = f"No changes detected after applying edits to '{file_path}'. Skipping commit."
self.logger.info(msg)
return msg
# Commit updated content using the Contents API
encoded_content = base64.b64encode(new_content.encode('utf-8')).decode('utf-8')
data = {
"message": commit_message,
"content": encoded_content,
"branch": self.current_branch,
"sha": current_sha
}
put_response = self.session.put(url, json=data)
if put_response.status_code in [200, 201]:
commit_sha = put_response.json().get("commit", {}).get("sha", "N/A")
success_message = f"Partial edits committed to '{file_path}' on branch '{self.current_branch}'. Commit SHA: {commit_sha}"
self.logger.info(success_message)
return success_message
else:
error_message = f"Error committing partial edits to '{file_path}': {put_response.status_code} - {put_response.text}"
self.logger.error(error_message)
return error_message
def _apply_line_edits(self, original_text, edits):
"""
Apply line-based edits to the provided text.
- Lines are 1-based.
- For each edit: replace lines [start_line..end_line] inclusive with 'replacement'.
If end_line < start_line, insert 'replacement' before start_line (no deletion).
- Preserves the file's original newline style (\n or \r\n) for joins.
"""
# Determine newline style
newline = "\r\n" if "\r\n" in original_text else "\n"
ends_with_newline = original_text.endswith("\n") or original_text.endswith("\r\n")
# Normalize the working representation to a list of lines without newlines
lines = original_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if ends_with_newline and (len(lines) == 0 or lines[-1] != ""):
# split drops the trailing empty element when text ends with newline; add it back as an empty logical line
lines.append("")
# Validate and apply edits in reverse order to avoid index shifts
sorted_edits = sorted(edits, key=lambda e: (e.get('start_line', 1), e.get('end_line', 0)), reverse=True)
for idx, edit in enumerate(sorted_edits):
start_line = edit.get('start_line')
end_line = edit.get('end_line')
replacement = edit.get('replacement', "")
if start_line is None or end_line is None:
raise ValueError(f"Edit #{idx+1} missing start_line or end_line")
if not isinstance(start_line, int) or not isinstance(end_line, int):
raise ValueError(f"Edit #{idx+1} start_line and end_line must be integers")
if start_line < 1:
raise ValueError(f"Edit #{idx+1} start_line must be >= 1")
# Normalize replacement to list of lines (without trailing newline)
rep_lines = replacement.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if end_line >= start_line:
# Replace lines in the inclusive range [start_line..end_line]
if end_line > len(lines):
raise ValueError(f"Edit #{idx+1} end_line {end_line} exceeds file length {len(lines)}")
# Python slice end is exclusive; convert to 0-based indices
s = start_line - 1
e = end_line
lines[s:e] = rep_lines
else:
# Insertion before start_line (no deletion)
if start_line > len(lines) + 1:
raise ValueError(f"Edit #{idx+1} start_line {start_line} is beyond end of file {len(lines)}")
insert_at = start_line - 1
lines[insert_at:insert_at] = rep_lines
# Reconstruct the text using the original newline style
text = newline.join(lines)
if not ends_with_newline and text.endswith(newline):
# Original did not end with newline; remove any trailing newline we may have introduced
text = text[:-len(newline)]
elif ends_with_newline and not text.endswith(newline):
# Original ended with newline; ensure we keep it
text += newline
return text
def _create_pull_request(self, title, body, base="main"):
self.logger.info(f"Creating pull request: '{title}' from branch '{self.current_branch}' to '{base}'")
self.logger.info(f"Creating pull request: '{title}' from branch '{self.current_branch}' to '{base}")
if self.current_branch == base:
error_message = f"Cannot create a pull request from branch '{self.current_branch}' to itself ('{base}')."
self.logger.warning(error_message)