Skip to content

Commit 497cbac

Browse files
devin-ai-integration[bot]patched-adminCTY-git
authored
feat: add diff object to FixIssue & ModifyCode steps (#1192)
* feat: add diff object to FixIssue and ModifyCode step outputs - Add diff field to ModifiedFile and ModifiedCodeFile types - Include file content diffs in FixIssue output - Add before/after diff information to ModifyCode output - Keep changes minimal while maintaining existing functionality Co-Authored-By: Patched <[email protected]> * refactor: improve diff generation in FixIssue and ModifyCode steps Co-Authored-By: Patched <[email protected]> * fix: address diff generation, security, and file handling issues for PR #1192 Co-Authored-By: Patched <[email protected]> * refactor: remove git dependency in FixIssue, switch to in-memory diff in FixIssue & ModifyCode Co-Authored-By: Patched <[email protected]> * feat: use git diff when in git repo, empty string otherwise Co-Authored-By: Patched <[email protected]> * refactor: replace print statements with logger.warning calls Co-Authored-By: Patched <[email protected]> * Some logic changes and more succint code * bump patchwork version --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Patched <[email protected]> Co-authored-by: TIANYOU CHEN <[email protected]>
1 parent 3e81f25 commit 497cbac

File tree

5 files changed

+157
-22
lines changed

5 files changed

+157
-22
lines changed

patchwork/steps/FixIssue/FixIssue.py

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import difflib
12
import re
23
from pathlib import Path
34
from typing import Any, Optional
45

5-
from git import Repo
6+
from git import Repo, InvalidGitRepositoryError
7+
from patchwork.logger import logger
68
from openai.types.chat import ChatCompletionMessageParam
79

810
from patchwork.common.client.llm.aio import AioLlmClient
@@ -97,11 +99,31 @@ def is_stop(self, messages: list[ChatCompletionMessageParam]) -> bool:
9799

98100
class FixIssue(Step, input_class=FixIssueInputs, output_class=FixIssueOutputs):
99101
def __init__(self, inputs):
102+
"""Initialize the FixIssue step.
103+
104+
Args:
105+
inputs: Dictionary containing input parameters including:
106+
- base_path: Optional path to the repository root
107+
- Other LLM-related parameters
108+
"""
100109
super().__init__(inputs)
101-
self.base_path = inputs.get("base_path")
102-
if self.base_path is None:
103-
repo = Repo(Path.cwd(), search_parent_directories=True)
104-
self.base_path = repo.working_tree_dir
110+
cwd = str(Path.cwd())
111+
original_base_path = inputs.get("base_path")
112+
113+
if original_base_path is not None:
114+
original_base_path = str(Path(str(original_base_path)).resolve())
115+
116+
# Check if we're in a git repository
117+
try:
118+
self.repo = Repo(original_base_path or cwd, search_parent_directories=True)
119+
except (InvalidGitRepositoryError, Exception):
120+
self.repo = None
121+
122+
repo_working_dir = None
123+
if self.repo is not None:
124+
repo_working_dir = self.repo.working_dir
125+
126+
self.base_path = original_base_path or repo_working_dir or cwd
105127

106128
llm_client = AioLlmClient.create_aio_client(inputs)
107129
if llm_client is None:
@@ -122,10 +144,40 @@ def __init__(self, inputs):
122144
)
123145

124146
def run(self):
147+
"""Execute the FixIssue step.
148+
149+
This method:
150+
1. Executes the multi-turn LLM conversation to analyze and fix the issue
151+
2. Tracks file modifications made by the CodeEditTool
152+
3. Generates in-memory diffs for all modified files
153+
154+
Returns:
155+
dict: Dictionary containing list of modified files with their diffs
156+
"""
125157
self.multiturn_llm_call.execute(limit=100)
158+
159+
modified_files = []
160+
cwd = Path.cwd()
126161
for tool in self.multiturn_llm_call.tool_set.values():
127-
if isinstance(tool, CodeEditTool):
128-
cwd = Path.cwd()
129-
modified_files = [file_path.relative_to(cwd) for file_path in tool.tool_records["modified_files"]]
130-
return dict(modified_files=[{"path": str(file)} for file in modified_files])
131-
return dict()
162+
if not isinstance(tool, CodeEditTool):
163+
continue
164+
tool_modified_files = [
165+
dict(path=str(file_path.relative_to(cwd)), diff="")
166+
for file_path in tool.tool_records["modified_files"]
167+
]
168+
modified_files.extend(tool_modified_files)
169+
170+
# Generate diffs for modified files
171+
# Only try to generate git diff if we're in a git repository
172+
if self.repo is not None:
173+
for modified_file in modified_files:
174+
file = modified_file["path"]
175+
try:
176+
# Try to get the diff using git
177+
diff = self.repo.git.diff('HEAD', file)
178+
modified_file["diff"] = diff or ""
179+
except Exception as e:
180+
# Git-specific errors (untracked files, etc) - keep empty diff
181+
logger.warning(f"Could not get git diff for {file}: {str(e)}")
182+
183+
return dict(modified_files=modified_files)

patchwork/steps/FixIssue/typed.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,21 @@ class FixIssueInputs(__FixIssueRequiredInputs, total=False):
3535
]
3636

3737

38+
class ModifiedFile(TypedDict):
39+
"""Represents a file that has been modified by the FixIssue step.
40+
41+
Attributes:
42+
path: The relative path to the modified file from the repository root
43+
diff: A unified diff string showing the changes made to the file.
44+
Generated using Python's difflib to compare the original and
45+
modified file contents in memory.
46+
47+
Note:
48+
The diff is generated by comparing file contents before and after
49+
modifications, without relying on version control systems.
50+
"""
51+
path: str
52+
diff: str
53+
3854
class FixIssueOutputs(TypedDict):
39-
modified_files: List[Dict]
55+
modified_files: List[ModifiedFile]

patchwork/steps/ModifyCode/ModifyCode.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
from __future__ import annotations
22

3+
import difflib
34
from pathlib import Path
45

6+
from patchwork.logger import logger
57
from patchwork.step import Step, StepStatus
68

79

8-
def save_file_contents(file_path, content):
9-
"""Utility function to save content to a file."""
10-
with open(file_path, "w") as file:
10+
def save_file_contents(file_path: str | Path, content: str) -> None:
11+
"""Utility function to save content to a file.
12+
13+
Args:
14+
file_path: Path to the file to save content to (str or Path)
15+
content: Content to write to the file
16+
"""
17+
path = Path(file_path)
18+
with path.open("w") as file:
1119
file.write(content)
1220

1321

@@ -33,20 +41,26 @@ def handle_indent(src: list[str], target: list[str], start: int, end: int) -> li
3341

3442

3543
def replace_code_in_file(
36-
file_path: str,
44+
file_path: str | Path,
3745
start_line: int | None,
3846
end_line: int | None,
3947
new_code: str,
4048
) -> None:
49+
"""Replace code in a file at the specified line range.
50+
51+
Args:
52+
file_path: Path to the file to modify (str or Path)
53+
start_line: Starting line number (1-based)
54+
end_line: Ending line number (1-based)
55+
new_code: New code to insert
56+
"""
4157
path = Path(file_path)
4258
new_code_lines = new_code.splitlines(keepends=True)
4359
if len(new_code_lines) > 0 and not new_code_lines[-1].endswith("\n"):
4460
new_code_lines[-1] += "\n"
4561

4662
if path.exists() and start_line is not None and end_line is not None:
47-
"""Replaces specified lines in a file with new code."""
4863
text = path.read_text()
49-
5064
lines = text.splitlines(keepends=True)
5165

5266
# Insert the new code at the start line after converting it into a list of lines
@@ -55,7 +69,7 @@ def replace_code_in_file(
5569
lines = new_code_lines
5670

5771
# Save the modified contents back to the file
58-
save_file_contents(file_path, "".join(lines))
72+
save_file_contents(path, "".join(lines))
5973

6074

6175
class ModifyCode(Step):
@@ -81,16 +95,53 @@ def run(self) -> dict:
8195
return dict(modified_code_files=[])
8296

8397
for code_snippet, extracted_response in sorted_list:
84-
uri = code_snippet.get("uri")
98+
# Use Path for consistent path handling
99+
file_path = Path(code_snippet.get("uri", ""))
85100
start_line = code_snippet.get("startLine")
86101
end_line = code_snippet.get("endLine")
87102
new_code = extracted_response.get("patch")
88103

89104
if new_code is None:
90105
continue
91106

92-
replace_code_in_file(uri, start_line, end_line, new_code)
93-
modified_code_file = dict(path=uri, start_line=start_line, end_line=end_line, **extracted_response)
107+
# Get the original content for diffing
108+
diff = ""
109+
try:
110+
# Store original content in memory
111+
original_content = file_path.read_text() if file_path.exists() else ""
112+
113+
# Apply the changes
114+
replace_code_in_file(file_path, start_line, end_line, new_code)
115+
116+
# Read modified content
117+
current_content = file_path.read_text() if file_path.exists() else ""
118+
119+
# Generate unified diff
120+
fromfile = f"a/{file_path}"
121+
tofile = f"b/{file_path}"
122+
diff = "".join(difflib.unified_diff(
123+
original_content.splitlines(keepends=True),
124+
current_content.splitlines(keepends=True),
125+
fromfile=fromfile,
126+
tofile=tofile
127+
))
128+
129+
if not diff and new_code: # If no diff but we have new code (new file)
130+
diff = f"+++ {file_path}\n{new_code}"
131+
except (OSError, IOError) as e:
132+
logger.warning(f"Failed to generate diff for {file_path}: {str(e)}")
133+
# Still proceed with the modification even if diff generation fails
134+
replace_code_in_file(file_path, start_line, end_line, new_code)
135+
diff = f"+++ {file_path}\n{new_code}" # Use new code as diff on error
136+
137+
# Create the modified code file dictionary
138+
modified_code_file = dict(
139+
path=str(file_path),
140+
start_line=start_line,
141+
end_line=end_line,
142+
diff=diff,
143+
**extracted_response
144+
)
94145
modified_code_files.append(modified_code_file)
95146

96147
return dict(modified_code_files=modified_code_files)

patchwork/steps/ModifyCode/typed.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ class ModifyCodeOutputs(TypedDict):
1111

1212

1313
class ModifiedCodeFile(TypedDict, total=False):
14+
"""Represents a file that has been modified by the ModifyCode step.
15+
16+
Attributes:
17+
path: The path to the modified file
18+
start_line: The starting line number of the modification (1-based)
19+
end_line: The ending line number of the modification (1-based)
20+
diff: A unified diff string showing the changes made to the file.
21+
Generated using Python's difflib for in-memory comparison
22+
of original and modified file contents.
23+
24+
Note:
25+
The diff field is generated using difflib.unified_diff() to compare
26+
the original and modified file contents in memory, ensuring efficient
27+
and secure diff generation.
28+
"""
1429
path: str
1530
start_line: int
1631
end_line: int
32+
diff: str

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "patchwork-cli"
3-
version = "0.0.90"
3+
version = "0.0.91"
44
description = ""
55
authors = ["patched.codes"]
66
license = "AGPL"

0 commit comments

Comments
 (0)