Skip to content

Commit f2e0b0f

Browse files
committed
generate patch with a tool after agent resolves conflicts
Signed-off-by: Tomas Tomecek <[email protected]>
1 parent c938d18 commit f2e0b0f

File tree

3 files changed

+189
-6
lines changed

3 files changed

+189
-6
lines changed

beeai/agents/backport_agent.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
from beeai_framework.tools.think import ThinkTool
2323

2424
from base_agent import BaseAgent, TInputSchema, TOutputSchema
25+
from tools.specfile import AddChangelogEntryTool, BumpReleaseTool
2526
from tools.text import CreateTool, InsertTool, StrReplaceTool, ViewTool
27+
from tools.wicked_git import GitPatchCreationTool
2628
from constants import COMMIT_PREFIX, BRANCH_PREFIX
2729
from observability import setup_observability
2830
from tools.commands import RunShellCommandTool
@@ -78,6 +80,9 @@ def __init__(self) -> None:
7880
ViewTool(),
7981
InsertTool(),
8082
StrReplaceTool(),
83+
GitPatchCreationTool(),
84+
BumpReleaseTool(),
85+
AddChangelogEntryTool(),
8186
],
8287
memory=UnconstrainedMemory(),
8388
requirements=[
@@ -92,7 +97,10 @@ def __init__(self) -> None:
9297
"Ignore pre-existing rpmlint warnings unless they're related to your changes",
9398
"Run `centpkg prep` to verify all patches apply cleanly during build preparation",
9499
"Generate an SRPM using `centpkg srpm` command to ensure complete build readiness",
95-
"Increment the 'Release' field in the .spec file following RPM packaging conventions",
100+
"Increment the 'Release' field in the .spec file following RPM packaging conventions "
101+
"using the `bump_release` tool",
102+
"Add a new changelog entry to the .spec file using the `add_changelog_entry` tool using name "
103+
"\"RHEL Packaging Agent <[email protected]>\"",
96104
"* IMPORTANT: Only perform changes relevant to the backport update"
97105
]
98106
)
@@ -136,11 +144,13 @@ def prompt(self) -> str:
136144
"Work inside the repository cloned at \"{{ git_repo_basepath }}/{{ package }}\"\n"
137145
"Download the upstream fix from {{ upstream_fix }}\n"
138146
"Store the patch file as \"{{ jira_issue }}.patch\" in the repository root\n"
139-
"Navigate to the directory {{ unpacked_sources }} and use `git am` "
147+
"Navigate to the directory {{ unpacked_sources }} and use `git am --reject` "
140148
"command to apply the patch {{ jira_issue }}.patch\n"
141-
"Resolve all conflicts inside {{ unpacked_sources }} directory\n"
142-
"Once all conflicts are resolved, run `git am --continue` to continue applying the patch\n"
143-
"Update the patch {{ jira_issue }}.patch file with the new changes from {{ unpacked_sources }}\n"
149+
"Resolve all conflicts inside {{ unpacked_sources }} directory and "
150+
"leave the repository in a dirty state\n"
151+
"Delete all *.rej files\n"
152+
"DO **NOT** RUN COMMAND `git am --continue`\n"
153+
"Once you resolve all conflicts, use tool git_patch_create to create a patch file\n"
144154
"{{ backport_git_steps }}"
145155
)
146156

beeai/agents/tools/specfile.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
from pathlib import Path
32
from typing import Any
43

beeai/agents/tools/wicked_git.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import asyncio
2+
from pathlib import Path
3+
4+
from pydantic import BaseModel, Field
5+
6+
from beeai_framework.context import RunContext
7+
from beeai_framework.emitter import Emitter
8+
from beeai_framework.tools import JSONToolOutput, Tool, ToolRunOptions
9+
10+
11+
class GitPatchCreationToolInput(BaseModel):
12+
repository_path: Path = Field(description="Absolute path to the git repository")
13+
patch_file_path: Path = Field(description="Absolute path where the patch file should be saved")
14+
15+
16+
class GitPatchCreationToolResult(BaseModel):
17+
success: bool = Field(description="Whether the patch creation was successful")
18+
patch_file_path: str = Field(description="Path to the created patch file")
19+
error: str | None = Field(description="Error message if patch creation failed", default=None)
20+
21+
22+
class GitPatchCreationToolOutput(JSONToolOutput[GitPatchCreationToolResult]):
23+
""" Returns a dictionary with success or error and the path to the created patch file. """
24+
25+
26+
async def run_command(cmd: list[str], cwd: Path) -> dict[str, str | int]:
27+
proc = await asyncio.create_subprocess_exec(
28+
cmd[0],
29+
*cmd[1:],
30+
stdout=asyncio.subprocess.PIPE,
31+
stderr=asyncio.subprocess.PIPE,
32+
cwd=cwd,
33+
)
34+
35+
stdout, stderr = await proc.communicate()
36+
37+
return {
38+
"exit_code": proc.returncode,
39+
"stdout": stdout.decode() if stdout else None,
40+
"stderr": stderr.decode() if stderr else None,
41+
}
42+
43+
class GitPatchCreationTool(Tool[GitPatchCreationToolInput, ToolRunOptions, GitPatchCreationToolOutput]):
44+
name = "git_patch_create"
45+
description = """
46+
Creates a patch file from the specified git repository with an active git-am session
47+
and after you resolved all merge conflicts. The tool generates a patch file that can be
48+
applied later in the RPM build process. Returns a dictionary with success or error and
49+
the path to the created patch file.
50+
"""
51+
input_schema = GitPatchCreationToolInput
52+
53+
def _create_emitter(self) -> Emitter:
54+
return Emitter.root().child(
55+
namespace=["tool", "git", self.name],
56+
creator=self,
57+
)
58+
59+
async def _run(
60+
self, tool_input: GitPatchCreationToolInput, options: ToolRunOptions | None, context: RunContext
61+
) -> GitPatchCreationToolOutput:
62+
# Ensure the repository path exists and is a git repository
63+
if not tool_input.repository_path.exists():
64+
return GitPatchCreationToolOutput(
65+
result=GitPatchCreationToolResult(
66+
success=False,
67+
patch_file_path="",
68+
patch_content="",
69+
error=f"Repository path does not exist: {tool_input.repository_path}"
70+
)
71+
)
72+
73+
git_dir = tool_input.repository_path / ".git"
74+
if not git_dir.exists():
75+
return GitPatchCreationToolOutput(
76+
result=GitPatchCreationToolResult(
77+
success=False,
78+
patch_file_path="",
79+
patch_content="",
80+
error=f"Not a git repository: {tool_input.repository_path}"
81+
)
82+
)
83+
84+
# list all untracked files in the repository
85+
cmd = ["git", "ls-files", "--others", "--exclude-standard"]
86+
result = await run_command(cmd, cwd=tool_input.repository_path)
87+
if result["exit_code"] != 0:
88+
return GitPatchCreationToolOutput(
89+
result=GitPatchCreationToolResult(
90+
success=False,
91+
patch_file_path="",
92+
patch_content="",
93+
error=f"Git command failed: {result['stderr']}"
94+
)
95+
)
96+
untracked_files = result["stdout"].splitlines()
97+
# list staged as well since that's what the agent usually does after it resolves conflicts
98+
cmd = ["git", "diff", "--name-only", "--cached"]
99+
result = await run_command(cmd, cwd=tool_input.repository_path)
100+
if result["exit_code"] != 0:
101+
return GitPatchCreationToolOutput(
102+
result=GitPatchCreationToolResult(
103+
success=False,
104+
patch_file_path="",
105+
patch_content="",
106+
error=f"Git command failed: {result['stderr']}"
107+
)
108+
)
109+
staged_files = result["stdout"].splitlines()
110+
all_files = untracked_files + staged_files
111+
# make sure there are no *.rej files in the repository
112+
rej_files = [file for file in all_files if file.endswith(".rej")]
113+
if rej_files:
114+
return GitPatchCreationToolOutput(
115+
result=GitPatchCreationToolResult(
116+
success=False,
117+
patch_file_path="",
118+
patch_content="",
119+
error="Merge conflicts detected in the repository: "
120+
f"{tool_input.repository_path}, {rej_files}"
121+
)
122+
)
123+
124+
# git-am leaves the repository in a dirty state, so we need to stage everything
125+
# I considered to inspect the patch and only stage the files that are changed by the patch,
126+
# but the backport process could create new files or change new ones
127+
# so let's go the naive route: git add -A
128+
cmd = ["git", "add", "-A"]
129+
result = await run_command(cmd, cwd=tool_input.repository_path)
130+
if result["exit_code"] != 0:
131+
return GitPatchCreationToolOutput(
132+
result=GitPatchCreationToolResult(
133+
success=False,
134+
patch_file_path="",
135+
patch_content="",
136+
error=f"Git command failed: {result['stderr']}"
137+
)
138+
)
139+
# continue git-am process
140+
cmd = ["git", "am", "--continue"]
141+
result = await run_command(cmd, cwd=tool_input.repository_path)
142+
if result["exit_code"] != 0:
143+
return GitPatchCreationToolOutput(
144+
result=GitPatchCreationToolResult(
145+
success=False,
146+
patch_file_path="",
147+
patch_content="",
148+
error=f"git-am failed: {result['stderr']}, out={result['stdout']}"
149+
)
150+
)
151+
# good, now we should have the patch committed, so let's get the file
152+
cmd = [
153+
"git", "format-patch",
154+
"--output",
155+
str(tool_input.patch_file_path),
156+
"HEAD~1..HEAD"
157+
]
158+
result = await run_command(cmd, cwd=tool_input.repository_path)
159+
if result["exit_code"] != 0:
160+
return GitPatchCreationToolOutput(
161+
result=GitPatchCreationToolResult(
162+
success=False,
163+
patch_file_path="",
164+
patch_content="",
165+
error=f"git-format-patch failed: {result['stderr']}"
166+
)
167+
)
168+
return GitPatchCreationToolOutput(
169+
result=GitPatchCreationToolResult(
170+
success=True,
171+
patch_file_path=str(tool_input.patch_file_path),
172+
error=None
173+
)
174+
)

0 commit comments

Comments
 (0)