Skip to content

Commit 0a6989a

Browse files
authored
Merge pull request packit#73 from TomasTomecek/rebase-patches-with-instructions
Backporting agent: rebasing patches
2 parents df49d16 + ecb3a89 commit 0a6989a

File tree

9 files changed

+325
-61
lines changed

9 files changed

+325
-61
lines changed

beeai/Containerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ RUN chgrp -R root /home/beeai && chmod -R g+rwX /home/beeai
4545
USER beeai
4646
WORKDIR /home/beeai
4747

48+
# so that we can start working with gitlab.com immediately
49+
RUN mkdir ~/.ssh \
50+
&& chmod 0700 ~/.ssh \
51+
&& ssh-keyscan gitlab.com >> ~/.ssh/known_hosts \
52+
&& git config --global user.email "[email protected]" \
53+
&& git config --global user.name "RHEL Packaging Agent"
54+
4855
CMD ["/bin/bash"]

beeai/agents/backport_agent.py

Lines changed: 104 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import asyncio
22
import logging
33
import os
4+
from shutil import rmtree
5+
from pathlib import Path
6+
import subprocess
47
import sys
58
import traceback
69
from typing import Optional
@@ -20,6 +23,9 @@
2023
from beeai_framework.tools.think import ThinkTool
2124

2225
from base_agent import BaseAgent, TInputSchema, TOutputSchema
26+
from tools.specfile import AddChangelogEntryTool, BumpReleaseTool
27+
from tools.text import CreateTool, InsertTool, StrReplaceTool, ViewTool
28+
from tools.wicked_git import GitPatchCreationTool
2329
from constants import COMMIT_PREFIX, BRANCH_PREFIX
2430
from observability import setup_observability
2531
from tools.commands import RunShellCommandTool
@@ -50,6 +56,10 @@ class InputSchema(BaseModel):
5056
description="Base path for cloned git repos",
5157
default=os.getenv("GIT_REPO_BASEPATH"),
5258
)
59+
unpacked_sources: str = Field(
60+
description="Path to the unpacked (using `centpkg prep`) sources",
61+
default="",
62+
)
5363

5464

5565
class OutputSchema(BaseModel):
@@ -63,12 +73,37 @@ class BackportAgent(BaseAgent):
6373
def __init__(self) -> None:
6474
super().__init__(
6575
llm=ChatModel.from_name(os.getenv("CHAT_MODEL")),
66-
tools=[ThinkTool(), RunShellCommandTool(), DuckDuckGoSearchTool()],
76+
tools=[
77+
ThinkTool(),
78+
RunShellCommandTool(),
79+
DuckDuckGoSearchTool(),
80+
CreateTool(),
81+
ViewTool(),
82+
InsertTool(),
83+
StrReplaceTool(),
84+
GitPatchCreationTool(),
85+
BumpReleaseTool(),
86+
AddChangelogEntryTool(),
87+
],
6788
memory=UnconstrainedMemory(),
6889
requirements=[
6990
ConditionalRequirement(ThinkTool, force_after=Tool, consecutive_allowed=False),
7091
],
7192
middlewares=[GlobalTrajectoryMiddleware(pretty=True)],
93+
role="Red Hat Enterprise Linux developer",
94+
instructions=[
95+
"Use the `think` tool to reason through complex decisions and document your approach.",
96+
"Preserve existing formatting and style conventions in RPM spec files and patch headers.",
97+
"Use `rpmlint *.spec` to check for packaging issues and address any NEW errors",
98+
"Ignore pre-existing rpmlint warnings unless they're related to your changes",
99+
"Run `centpkg prep` to verify all patches apply cleanly during build preparation",
100+
"Generate an SRPM using `centpkg srpm` command to ensure complete build readiness",
101+
"Increment the 'Release' field in the .spec file following RPM packaging conventions "
102+
"using the `bump_release` tool",
103+
"Add a new changelog entry to the .spec file using the `add_changelog_entry` tool using name "
104+
"\"RHEL Packaging Agent <[email protected]>\"",
105+
"* IMPORTANT: Only perform changes relevant to the backport update"
106+
]
72107
)
73108

74109
@property
@@ -89,8 +124,6 @@ def backport_git_steps(data: dict) -> str:
89124
commit_title=f"{COMMIT_PREFIX} backport {input_data.jira_issue}",
90125
files_to_commit=f"*.spec and {input_data.jira_issue}.patch",
91126
branch_name=f"{BRANCH_PREFIX}-{input_data.jira_issue}",
92-
git_user=input_data.git_user,
93-
git_email=input_data.git_email,
94127
git_url=input_data.git_url,
95128
dist_git_branch=input_data.dist_git_branch,
96129
)
@@ -108,41 +141,19 @@ def backport_git_steps(data: dict) -> str:
108141

109142
@property
110143
def prompt(self) -> str:
111-
return """
112-
You are an agent for backporting a fix for a CentOS Stream package. You will prepare the content
113-
of the update and then create a commit with the changes. Create a temporary directory and always work
114-
inside it. Follow exactly these steps:
115-
116-
1. Find the location of the {{ package }} package at {{ git_url }}. Always use the {{ dist_git_branch }} branch.
117-
118-
2. Check if the package {{ package }} already has the fix {{ jira_issue }} applied.
119-
120-
3. Create a local Git repository by following these steps:
121-
* Create a fork of the {{ package }} package using the `fork_repository` tool.
122-
* Clone the fork using git and HTTPS into a temporary directory under {{ git_repo_basepath }}.
123-
* Run command `centpkg sources` in the cloned repository which downloads all sources defined in the RPM specfile.
124-
* Create a new Git branch named `automated-package-update-{{ jira_issue }}`.
125-
126-
4. Update the {{ package }} with the fix:
127-
* Updating the 'Release' field in the .spec file as needed (or corresponding macros), following packaging
128-
documentation.
129-
* Make sure the format of the .spec file remains the same.
130-
* Fetch the upstream fix {{ upstream_fix }} locally and store it in the git repo as "{{ jira_issue }}.patch".
131-
* Add a new "Patch:" entry in the spec file for patch "{{ jira_issue }}.patch".
132-
* Verify that the patch is being applied in the "%prep" section.
133-
* Creating a changelog entry, referencing the Jira issue as "Resolves: <jira_issue>" for the issue {{ jira_issue }}.
134-
The changelog entry has to use the current date.
135-
* IMPORTANT: Only performing changes relevant to the backport update: Do not rename variables,
136-
comment out existing lines, or alter if-else branches in the .spec file.
137-
138-
5. Verify and adjust the changes:
139-
* Use `rpmlint` to validate your .spec file changes and fix any new errors it identifies.
140-
* Generate the SRPM using `rpmbuild -bs` (ensure your .spec file and source files are correctly copied
141-
to the build environment as required by the command).
142-
* Verify the newly added patch applies cleanly using the command `centpkg prep`.
143-
144-
6. {{ backport_git_steps }}
145-
"""
144+
return (
145+
"Work inside the repository cloned at \"{{ git_repo_basepath }}/{{ package }}\"\n"
146+
"Download the upstream fix from {{ upstream_fix }}\n"
147+
"Store the patch file as \"{{ jira_issue }}.patch\" in the repository root\n"
148+
"Navigate to the directory {{ unpacked_sources }} and use `git am --reject` "
149+
"command to apply the patch {{ jira_issue }}.patch\n"
150+
"Resolve all conflicts inside {{ unpacked_sources }} directory and "
151+
"leave the repository in a dirty state\n"
152+
"Delete all *.rej files\n"
153+
"DO **NOT** RUN COMMAND `git am --continue`\n"
154+
"Once you resolve all conflicts, use tool git_patch_create to create a patch file\n"
155+
"{{ backport_git_steps }}"
156+
)
146157

147158
async def run_with_schema(self, input: TInputSchema) -> TOutputSchema:
148159
async with mcp_tools(
@@ -162,11 +173,52 @@ async def run_with_schema(self, input: TInputSchema) -> TOutputSchema:
162173
requirement._source_tool = None
163174

164175

176+
def prepare_package(package: str, jira_issue: str, dist_git_branch: str,
177+
input_schema: InputSchema) -> tuple[Path, Path]:
178+
"""
179+
Prepare the package for backporting by cloning the dist-git repository, switching to the appropriate branch,
180+
and downloading the sources.
181+
Returns the path to the unpacked sources.
182+
"""
183+
git_repo = Path(input_schema.git_repo_basepath)
184+
git_repo.mkdir(parents=True, exist_ok=True)
185+
subprocess.check_call(
186+
[
187+
"centpkg",
188+
"clone",
189+
"--anonymous",
190+
"--branch",
191+
dist_git_branch,
192+
package,
193+
],
194+
cwd=git_repo,
195+
)
196+
local_clone = git_repo / package
197+
subprocess.check_call(
198+
[
199+
"git",
200+
"switch",
201+
"-c",
202+
f"automated-package-update-{jira_issue}",
203+
dist_git_branch,
204+
],
205+
cwd=local_clone,
206+
)
207+
subprocess.check_call(["centpkg", "sources"], cwd=local_clone)
208+
subprocess.check_call(["centpkg", "prep"], cwd=local_clone)
209+
unpacked_sources = list(local_clone.glob(f"*-build/*{package}*"))
210+
if len(unpacked_sources) != 1:
211+
raise ValueError(
212+
f"Expected exactly one unpacked source, got {unpacked_sources}"
213+
)
214+
return unpacked_sources[0], local_clone
215+
165216
async def main() -> None:
166217
logging.basicConfig(level=logging.INFO)
167218

168219
setup_observability(os.getenv("COLLECTOR_ENDPOINT"))
169220
agent = BackportAgent()
221+
dry_run = os.getenv("DRY_RUN", "False").lower() == "true"
170222

171223
if (
172224
(package := os.getenv("PACKAGE", None))
@@ -181,7 +233,16 @@ async def main() -> None:
181233
jira_issue=jira_issue,
182234
dist_git_branch=branch,
183235
)
184-
output = await agent.run_with_schema(input)
236+
unpacked_sources, local_clone = prepare_package(package, jira_issue, branch, input)
237+
input.unpacked_sources = str(unpacked_sources)
238+
try:
239+
output = await agent.run_with_schema(input)
240+
finally:
241+
if not dry_run:
242+
logger.info(f"Removing {local_clone}")
243+
rmtree(local_clone)
244+
else:
245+
logger.info(f"DRY RUN: Not removing {local_clone}")
185246
logger.info(f"Direct run completed: {output.model_dump_json(indent=4)}")
186247
return
187248

@@ -215,6 +276,8 @@ class Task(BaseModel):
215276
jira_issue=backport_data.jira_issue,
216277
dist_git_branch=backport_data.branch,
217278
)
279+
input.unpacked_sources, local_clone = prepare_package(backport_data.package,
280+
backport_data.jira_issue, backport_data.branch, input)
218281

219282
async def retry(task, error):
220283
task.attempts += 1
@@ -238,7 +301,9 @@ async def retry(task, error):
238301
await retry(
239302
task, ErrorData(details=error, jira_issue=input.jira_issue).model_dump_json()
240303
)
304+
rmtree(local_clone)
241305
else:
306+
rmtree(local_clone)
242307
if output.success:
243308
logger.info(f"Backport successful for {backport_data.jira_issue}, "
244309
f"adding to completed list")

beeai/agents/tests/unit/test_tools.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import subprocess
23
from textwrap import dedent
34

45
import pytest
@@ -7,6 +8,7 @@
78

89
from beeai_framework.middleware.trajectory import GlobalTrajectoryMiddleware
910

11+
from tools.wicked_git import GitPatchCreationTool, GitPatchCreationToolInput
1012
from tools.commands import RunShellCommandTool, RunShellCommandToolInput
1113
from tools.specfile import (
1214
AddChangelogEntryTool,
@@ -279,3 +281,70 @@ async def test_str_replace(tmp_path):
279281
"""
280282
)[1:]
281283
)
284+
285+
@pytest.mark.asyncio
286+
async def test_git_patch_creation_tool_nonexistent_repo(tmp_path):
287+
# This test checks the error message for a non-existent repo path
288+
repo_path = tmp_path / "not_a_repo"
289+
patch_file_path = tmp_path / "patch.patch"
290+
tool = GitPatchCreationTool()
291+
output = await tool.run(
292+
input=GitPatchCreationToolInput(
293+
repository_path=str(repo_path),
294+
patch_file_path=str(patch_file_path),
295+
)
296+
).middleware(GlobalTrajectoryMiddleware(pretty=True))
297+
result = output.result
298+
assert "ERROR: Repository path does not exist" in result
299+
300+
@pytest.fixture
301+
def git_repo(tmp_path):
302+
repo_path = tmp_path / "repo"
303+
repo_path.mkdir()
304+
subprocess.run(["git", "init"], cwd=repo_path, check=True)
305+
# Create a file and commit it
306+
file_path = repo_path / "file.txt"
307+
file_path.write_text("Line 1\n")
308+
subprocess.run(["git", "add", "file.txt"], cwd=repo_path, check=True)
309+
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True)
310+
file_path.write_text("Line1\nLine 2\n")
311+
subprocess.run(["git", "add", "file.txt"], cwd=repo_path, check=True)
312+
subprocess.run(["git", "commit", "-m", "Initial commit2"], cwd=repo_path, check=True)
313+
subprocess.run(["git", "branch", "line-2"], cwd=repo_path, check=True)
314+
return repo_path
315+
316+
@pytest.mark.asyncio
317+
async def test_git_patch_creation_tool_success(git_repo, tmp_path):
318+
# Simulate a git-am session by creating a new commit and then using format-patch
319+
# Create a new file and stage it
320+
subprocess.run(["git", "reset", "--hard", "HEAD~1"], cwd=git_repo, check=True)
321+
new_file = git_repo / "file.txt"
322+
new_file.write_text("Line 1\nLine 3\n")
323+
subprocess.run(["git", "add", "file.txt"], cwd=git_repo, check=True)
324+
subprocess.run(["git", "commit", "-m", "Add line 3"], cwd=git_repo, check=True)
325+
326+
patch_file = tmp_path / "patch.patch"
327+
subprocess.run(["git", "format-patch", "-1", "HEAD", "--stdout"], cwd=git_repo, check=True, stdout=patch_file.open("w"))
328+
329+
subprocess.run(["git", "switch", "line-2"], cwd=git_repo, check=True)
330+
331+
# Now apply the patch with git am
332+
# This will fail with a merge conflict, but we don't care about that
333+
subprocess.run(["git", "am", str(patch_file)], cwd=git_repo)
334+
335+
new_file.write_text("Line 1\nLine 2\nLine 3\n")
336+
337+
# Now use the tool to create a patch file from the repo
338+
tool = GitPatchCreationTool()
339+
output_patch = tmp_path / "output.patch"
340+
output = await tool.run(
341+
input=GitPatchCreationToolInput(
342+
repository_path=str(git_repo),
343+
patch_file_path=str(output_patch),
344+
)
345+
).middleware(GlobalTrajectoryMiddleware(pretty=True))
346+
result = output.result
347+
assert "Successfully created a patch file" in result
348+
assert output_patch.exists()
349+
# The patch file should contain the commit message "Add line 3"
350+
assert "Add line 3" in output_patch.read_text()

beeai/agents/tools/commands.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from beeai_framework.emitter import Emitter
88
from beeai_framework.tools import JSONToolOutput, Tool, ToolRunOptions
99

10+
from tools.utils import run_command
11+
1012

1113
class RunShellCommandToolInput(BaseModel):
1214
command: str = Field(description="Command to run")
@@ -39,18 +41,6 @@ def _create_emitter(self) -> Emitter:
3941
async def _run(
4042
self, tool_input: RunShellCommandToolInput, options: ToolRunOptions | None, context: RunContext
4143
) -> RunShellCommandToolOutput:
42-
proc = await asyncio.create_subprocess_shell(
43-
tool_input.command,
44-
stdout=asyncio.subprocess.PIPE,
45-
stderr=asyncio.subprocess.PIPE,
46-
)
47-
48-
stdout, stderr = await proc.communicate()
49-
50-
result = {
51-
"exit_code": proc.returncode,
52-
"stdout": stdout.decode() if stdout else None,
53-
"stderr": stderr.decode() if stderr else None,
54-
}
44+
result = await run_command(tool_input.command, subprocess_function=asyncio.create_subprocess_shell)
5545

5646
return RunShellCommandToolOutput(RunShellCommandToolResult.model_validate(result))

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

0 commit comments

Comments
 (0)