Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions beeai/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ RUN chgrp -R root /home/beeai && chmod -R g+rwX /home/beeai
USER beeai
WORKDIR /home/beeai

# so that we can start working with gitlab.com immediately
RUN mkdir ~/.ssh \
&& chmod 0700 ~/.ssh \
&& ssh-keyscan gitlab.com >> ~/.ssh/known_hosts \
&& git config --global user.email "jotnar@redhat.com" \
Comment thread
TomasTomecek marked this conversation as resolved.
&& git config --global user.name "RHEL Packaging Agent"

CMD ["/bin/bash"]
143 changes: 104 additions & 39 deletions beeai/agents/backport_agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import asyncio
import logging
import os
from shutil import rmtree
from pathlib import Path
import subprocess
import sys
import traceback
from typing import Optional
Expand All @@ -20,6 +23,9 @@
from beeai_framework.tools.think import ThinkTool

from base_agent import BaseAgent, TInputSchema, TOutputSchema
from tools.specfile import AddChangelogEntryTool, BumpReleaseTool
from tools.text import CreateTool, InsertTool, StrReplaceTool, ViewTool
from tools.wicked_git import GitPatchCreationTool
from constants import COMMIT_PREFIX, BRANCH_PREFIX
from observability import setup_observability
from tools.commands import RunShellCommandTool
Expand Down Expand Up @@ -50,6 +56,10 @@ class InputSchema(BaseModel):
description="Base path for cloned git repos",
default=os.getenv("GIT_REPO_BASEPATH"),
)
unpacked_sources: str = Field(
description="Path to the unpacked (using `centpkg prep`) sources",
default="",
)


class OutputSchema(BaseModel):
Expand All @@ -63,12 +73,37 @@ class BackportAgent(BaseAgent):
def __init__(self) -> None:
super().__init__(
llm=ChatModel.from_name(os.getenv("CHAT_MODEL")),
tools=[ThinkTool(), RunShellCommandTool(), DuckDuckGoSearchTool()],
tools=[
ThinkTool(),
RunShellCommandTool(),
DuckDuckGoSearchTool(),
CreateTool(),
ViewTool(),
InsertTool(),
StrReplaceTool(),
GitPatchCreationTool(),
BumpReleaseTool(),
AddChangelogEntryTool(),
],
memory=UnconstrainedMemory(),
requirements=[
ConditionalRequirement(ThinkTool, force_after=Tool, consecutive_allowed=False),
],
middlewares=[GlobalTrajectoryMiddleware(pretty=True)],
role="Red Hat Enterprise Linux developer",
instructions=[
"Use the `think` tool to reason through complex decisions and document your approach.",
"Preserve existing formatting and style conventions in RPM spec files and patch headers.",
"Use `rpmlint *.spec` to check for packaging issues and address any NEW errors",
"Ignore pre-existing rpmlint warnings unless they're related to your changes",
"Run `centpkg prep` to verify all patches apply cleanly during build preparation",
"Generate an SRPM using `centpkg srpm` command to ensure complete build readiness",
"Increment the 'Release' field in the .spec file following RPM packaging conventions "
"using the `bump_release` tool",
"Add a new changelog entry to the .spec file using the `add_changelog_entry` tool using name "
"\"RHEL Packaging Agent <jotnar@redhat.com>\"",
"* IMPORTANT: Only perform changes relevant to the backport update"
]
)

@property
Expand All @@ -89,8 +124,6 @@ def backport_git_steps(data: dict) -> str:
commit_title=f"{COMMIT_PREFIX} backport {input_data.jira_issue}",
files_to_commit=f"*.spec and {input_data.jira_issue}.patch",
branch_name=f"{BRANCH_PREFIX}-{input_data.jira_issue}",
git_user=input_data.git_user,
git_email=input_data.git_email,
git_url=input_data.git_url,
dist_git_branch=input_data.dist_git_branch,
)
Expand All @@ -108,41 +141,19 @@ def backport_git_steps(data: dict) -> str:

@property
def prompt(self) -> str:
return """
You are an agent for backporting a fix for a CentOS Stream package. You will prepare the content
of the update and then create a commit with the changes. Create a temporary directory and always work
inside it. Follow exactly these steps:

1. Find the location of the {{ package }} package at {{ git_url }}. Always use the {{ dist_git_branch }} branch.

2. Check if the package {{ package }} already has the fix {{ jira_issue }} applied.

3. Create a local Git repository by following these steps:
* Create a fork of the {{ package }} package using the `fork_repository` tool.
* Clone the fork using git and HTTPS into a temporary directory under {{ git_repo_basepath }}.
* Run command `centpkg sources` in the cloned repository which downloads all sources defined in the RPM specfile.
* Create a new Git branch named `automated-package-update-{{ jira_issue }}`.

4. Update the {{ package }} with the fix:
* Updating the 'Release' field in the .spec file as needed (or corresponding macros), following packaging
documentation.
* Make sure the format of the .spec file remains the same.
* Fetch the upstream fix {{ upstream_fix }} locally and store it in the git repo as "{{ jira_issue }}.patch".
* Add a new "Patch:" entry in the spec file for patch "{{ jira_issue }}.patch".
* Verify that the patch is being applied in the "%prep" section.
* Creating a changelog entry, referencing the Jira issue as "Resolves: <jira_issue>" for the issue {{ jira_issue }}.
The changelog entry has to use the current date.
* IMPORTANT: Only performing changes relevant to the backport update: Do not rename variables,
comment out existing lines, or alter if-else branches in the .spec file.

5. Verify and adjust the changes:
* Use `rpmlint` to validate your .spec file changes and fix any new errors it identifies.
* Generate the SRPM using `rpmbuild -bs` (ensure your .spec file and source files are correctly copied
to the build environment as required by the command).
* Verify the newly added patch applies cleanly using the command `centpkg prep`.

6. {{ backport_git_steps }}
"""
return (
"Work inside the repository cloned at \"{{ git_repo_basepath }}/{{ package }}\"\n"
Comment thread
TomasTomecek marked this conversation as resolved.
"Download the upstream fix from {{ upstream_fix }}\n"
"Store the patch file as \"{{ jira_issue }}.patch\" in the repository root\n"
"Navigate to the directory {{ unpacked_sources }} and use `git am --reject` "
"command to apply the patch {{ jira_issue }}.patch\n"
"Resolve all conflicts inside {{ unpacked_sources }} directory and "
"leave the repository in a dirty state\n"
"Delete all *.rej files\n"
"DO **NOT** RUN COMMAND `git am --continue`\n"
"Once you resolve all conflicts, use tool git_patch_create to create a patch file\n"
"{{ backport_git_steps }}"
)

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


def prepare_package(package: str, jira_issue: str, dist_git_branch: str,
input_schema: InputSchema) -> tuple[Path, Path]:
"""
Prepare the package for backporting by cloning the dist-git repository, switching to the appropriate branch,
and downloading the sources.
Returns the path to the unpacked sources.
"""
git_repo = Path(input_schema.git_repo_basepath)
git_repo.mkdir(parents=True, exist_ok=True)
subprocess.check_call(
[
"centpkg",
"clone",
"--anonymous",
"--branch",
dist_git_branch,
package,
],
cwd=git_repo,
)
local_clone = git_repo / package
subprocess.check_call(
[
"git",
"switch",
"-c",
f"automated-package-update-{jira_issue}",
dist_git_branch,
],
cwd=local_clone,
)
subprocess.check_call(["centpkg", "sources"], cwd=local_clone)
subprocess.check_call(["centpkg", "prep"], cwd=local_clone)
unpacked_sources = list(local_clone.glob(f"*-build/*{package}*"))
if len(unpacked_sources) != 1:
raise ValueError(
f"Expected exactly one unpacked source, got {unpacked_sources}"
)
return unpacked_sources[0], local_clone

async def main() -> None:
logging.basicConfig(level=logging.INFO)

setup_observability(os.getenv("COLLECTOR_ENDPOINT"))
agent = BackportAgent()
dry_run = os.getenv("DRY_RUN", "False").lower() == "true"

if (
(package := os.getenv("PACKAGE", None))
Expand All @@ -181,7 +233,16 @@ async def main() -> None:
jira_issue=jira_issue,
dist_git_branch=branch,
)
output = await agent.run_with_schema(input)
unpacked_sources, local_clone = prepare_package(package, jira_issue, branch, input)
input.unpacked_sources = str(unpacked_sources)
try:
output = await agent.run_with_schema(input)
finally:
if not dry_run:
logger.info(f"Removing {local_clone}")
rmtree(local_clone)
else:
logger.info(f"DRY RUN: Not removing {local_clone}")
logger.info(f"Direct run completed: {output.model_dump_json(indent=4)}")
return

Expand Down Expand Up @@ -215,6 +276,8 @@ class Task(BaseModel):
jira_issue=backport_data.jira_issue,
dist_git_branch=backport_data.branch,
)
input.unpacked_sources, local_clone = prepare_package(backport_data.package,
backport_data.jira_issue, backport_data.branch, input)

async def retry(task, error):
task.attempts += 1
Expand All @@ -238,7 +301,9 @@ async def retry(task, error):
await retry(
task, ErrorData(details=error, jira_issue=input.jira_issue).model_dump_json()
)
rmtree(local_clone)
else:
rmtree(local_clone)
if output.success:
logger.info(f"Backport successful for {backport_data.jira_issue}, "
f"adding to completed list")
Expand Down
69 changes: 69 additions & 0 deletions beeai/agents/tests/unit/test_tools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import subprocess
from textwrap import dedent

import pytest
Expand All @@ -7,6 +8,7 @@

from beeai_framework.middleware.trajectory import GlobalTrajectoryMiddleware

from tools.wicked_git import GitPatchCreationTool, GitPatchCreationToolInput
from tools.commands import RunShellCommandTool, RunShellCommandToolInput
from tools.specfile import (
AddChangelogEntryTool,
Expand Down Expand Up @@ -279,3 +281,70 @@ async def test_str_replace(tmp_path):
"""
)[1:]
)

@pytest.mark.asyncio
async def test_git_patch_creation_tool_nonexistent_repo(tmp_path):
# This test checks the error message for a non-existent repo path
repo_path = tmp_path / "not_a_repo"
patch_file_path = tmp_path / "patch.patch"
tool = GitPatchCreationTool()
output = await tool.run(
input=GitPatchCreationToolInput(
repository_path=str(repo_path),
patch_file_path=str(patch_file_path),
)
).middleware(GlobalTrajectoryMiddleware(pretty=True))
result = output.result
assert "ERROR: Repository path does not exist" in result

@pytest.fixture
def git_repo(tmp_path):
repo_path = tmp_path / "repo"
repo_path.mkdir()
subprocess.run(["git", "init"], cwd=repo_path, check=True)
# Create a file and commit it
file_path = repo_path / "file.txt"
file_path.write_text("Line 1\n")
subprocess.run(["git", "add", "file.txt"], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True)
file_path.write_text("Line1\nLine 2\n")
subprocess.run(["git", "add", "file.txt"], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Initial commit2"], cwd=repo_path, check=True)
subprocess.run(["git", "branch", "line-2"], cwd=repo_path, check=True)
return repo_path

@pytest.mark.asyncio
async def test_git_patch_creation_tool_success(git_repo, tmp_path):
# Simulate a git-am session by creating a new commit and then using format-patch
# Create a new file and stage it
subprocess.run(["git", "reset", "--hard", "HEAD~1"], cwd=git_repo, check=True)
new_file = git_repo / "file.txt"
new_file.write_text("Line 1\nLine 3\n")
subprocess.run(["git", "add", "file.txt"], cwd=git_repo, check=True)
subprocess.run(["git", "commit", "-m", "Add line 3"], cwd=git_repo, check=True)

patch_file = tmp_path / "patch.patch"
subprocess.run(["git", "format-patch", "-1", "HEAD", "--stdout"], cwd=git_repo, check=True, stdout=patch_file.open("w"))

subprocess.run(["git", "switch", "line-2"], cwd=git_repo, check=True)

# Now apply the patch with git am
# This will fail with a merge conflict, but we don't care about that
subprocess.run(["git", "am", str(patch_file)], cwd=git_repo)

new_file.write_text("Line 1\nLine 2\nLine 3\n")

# Now use the tool to create a patch file from the repo
tool = GitPatchCreationTool()
output_patch = tmp_path / "output.patch"
output = await tool.run(
input=GitPatchCreationToolInput(
repository_path=str(git_repo),
patch_file_path=str(output_patch),
)
).middleware(GlobalTrajectoryMiddleware(pretty=True))
result = output.result
assert "Successfully created a patch file" in result
assert output_patch.exists()
# The patch file should contain the commit message "Add line 3"
assert "Add line 3" in output_patch.read_text()
16 changes: 3 additions & 13 deletions beeai/agents/tools/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from beeai_framework.emitter import Emitter
from beeai_framework.tools import JSONToolOutput, Tool, ToolRunOptions

from tools.utils import run_command


class RunShellCommandToolInput(BaseModel):
command: str = Field(description="Command to run")
Expand Down Expand Up @@ -39,18 +41,6 @@ def _create_emitter(self) -> Emitter:
async def _run(
self, tool_input: RunShellCommandToolInput, options: ToolRunOptions | None, context: RunContext
) -> RunShellCommandToolOutput:
proc = await asyncio.create_subprocess_shell(
tool_input.command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await proc.communicate()

result = {
"exit_code": proc.returncode,
"stdout": stdout.decode() if stdout else None,
"stderr": stderr.decode() if stderr else None,
}
result = await run_command(tool_input.command, subprocess_function=asyncio.create_subprocess_shell)

return RunShellCommandToolOutput(RunShellCommandToolResult.model_validate(result))
1 change: 0 additions & 1 deletion beeai/agents/tools/specfile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
from pathlib import Path
from typing import Any

Expand Down
Loading