Skip to content
Open
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
2 changes: 2 additions & 0 deletions ymir/tools/privileged/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
GetInternalRhelBranchesTool,
GetMergeRequestDetailsTool,
GetPatchFromUrlTool,
GetPipelineJobLogTool,
ListProjectMergeRequestsTool,
OpenMergeRequestTool,
PushToRemoteRepositoryTool,
Expand Down Expand Up @@ -155,6 +156,7 @@ async def _async_main():
ForkRepositoryTool(options=tool_options),
GetAuthorizedCommentsFromMergeRequestTool(options=tool_options),
GetFailedPipelineJobsFromMergeRequestTool(options=tool_options),
GetPipelineJobLogTool(options=tool_options),
GetInternalRhelBranchesTool(options=tool_options),
GetMergeRequestDetailsTool(options=tool_options),
GetPatchFromUrlTool(options=tool_options),
Expand Down
60 changes: 58 additions & 2 deletions ymir/tools/privileged/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,62 @@ def get_latest_pipeline_jobs():
raise ToolError(f"Failed to get failed jobs from merge request: {e}") from e


MAX_LOG_LINES = 500


class GetPipelineJobLogToolInput(BaseModel):
project_path: str = Field(description="GitLab project path (e.g. 'redhat/rhel/rpms/curl')")
job_id: str = Field(description="GitLab job ID (from get_failed_pipeline_jobs_from_merge_request output)")


class GetPipelineJobLogTool(Tool[GetPipelineJobLogToolInput, ToolRunOptions, StringToolOutput]):
name = "get_pipeline_job_log"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently, this MCP tool isn't wired to any agent, is this supposed to be used by preliminary agent here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, alongside the get_failed_pipeline_jobs_from_merge_request, but it's not necessarily meant to be used by a specific agent.

description = (
"Fetches the raw log output from a specific GitLab CI job. "
"Use after get_failed_pipeline_jobs_from_merge_request to read WHY a job failed. "
"Returns the last 500 lines of log output."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can start with this, but could also consider the approach used for Copr build logs previously - download the full trace to disk and let the agent view the content iteratively with offset/limit

)
input_schema = GetPipelineJobLogToolInput

def _create_emitter(self) -> Emitter:
return Emitter.root().child(
namespace=["tool", "gitlab", self.name],
creator=self,
)

async def _run(
self,
tool_input: GetPipelineJobLogToolInput,
options: ToolRunOptions | None,
context: RunContext,
) -> StringToolOutput:
encoded_path = quote(tool_input.project_path, safe="")
url = f"https://gitlab.com/api/v4/projects/{encoded_path}/jobs/{tool_input.job_id}/trace"
headers = _get_auth_headers(f"https://gitlab.com/{tool_input.project_path}")

try:
async with (
aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session,
Comment thread
martinhoyer marked this conversation as resolved.
aiohttp_get_with_retries(session, url, headers=headers) as response,
):
response.raise_for_status()
raw = await response.read()
log_text = raw.decode("utf-8", errors="replace")
except (aiohttp.ClientError, TimeoutError) as e:
raise ToolError(
f"Failed to fetch log for job {tool_input.job_id} in {tool_input.project_path}: {e}"
) from e
Comment thread
martinhoyer marked this conversation as resolved.

lines = log_text.splitlines()
if len(lines) > MAX_LOG_LINES:
log_text = (
f"[... truncated, showing last {MAX_LOG_LINES} of {len(lines)} lines ...]\n"
+ "\n".join(lines[-MAX_LOG_LINES:])
)

return StringToolOutput(result=log_text)


def _get_authorized_member_ids(project: GitlabProject) -> set[int]:
"""
Fetch all project members and return a set of IDs for members
Expand Down Expand Up @@ -1056,7 +1112,7 @@ async def _run(
) -> StringToolOutput:
patch_url = tool_input.patch_url
request_url = _get_api_diff_url(patch_url)
headers = _get_auth_headers(request_url)
headers = _get_auth_headers(patch_url)

try:
async with (
Expand Down Expand Up @@ -1117,7 +1173,7 @@ async def _run(
) -> StringToolOutput:
encoded_project = quote(input.project, safe="")
url = f"https://gitlab.com/api/v4/projects/{encoded_project}/merge_requests/{input.mr_iid}/notes"
headers = _get_auth_headers(url)
headers = _get_auth_headers(f"https://gitlab.com/{input.project}")
logger.info("Fetching MR notes from %s", url)

try:
Expand Down
57 changes: 57 additions & 0 deletions ymir/tools/privileged/tests/unit/test_gitlab.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path

import aiohttp
import gitlab
import pytest
from beeai_framework.tools import ToolError
Expand All @@ -19,6 +21,7 @@
ForkRepositoryTool,
GetAuthorizedCommentsFromMergeRequestTool,
GetFailedPipelineJobsFromMergeRequestTool,
GetPipelineJobLogTool,
OpenMergeRequestTool,
PushToRemoteRepositoryTool,
RetryPipelineJobTool,
Expand Down Expand Up @@ -897,3 +900,57 @@ async def test_get_authorized_comments_invalid_url():
input={"merge_request_url": "https://github.com/user/repo/pull/123"}
)
assert "Could not parse merge request URL" in str(exc_info.value)


@pytest.mark.asyncio
async def test_get_pipeline_job_log():
"""Verify GetPipelineJobLogTool fetches job trace and returns log text."""
project_path = "redhat/rhel/rpms/curl"
job_id = "12345"
log_content = "Running tests...\nFAILED: test_curl_connect\nExit code: 1"

@asynccontextmanager
async def mock_get(url, **kwargs):
assert f"/projects/redhat%2Frhel%2Frpms%2Fcurl/jobs/{job_id}/trace" in url

async def read():
return log_content.encode("utf-8")

yield flexmock(status=200, read=read, raise_for_status=lambda: None)

flexmock(aiohttp.ClientSession).should_receive("get").replace_with(mock_get)
flexmock(os).should_call("getenv")
flexmock(os).should_receive("getenv").with_args("GITLAB_TOKEN").and_return("test-token").once()

tool = GetPipelineJobLogTool()
result = await tool.run(
input={"project_path": project_path, "job_id": job_id},
)
assert "FAILED: test_curl_connect" in result.result


@pytest.mark.asyncio
async def test_get_pipeline_job_log_truncates_large_output():
"""Verify logs exceeding MAX_LOG_LINES are truncated to last N lines."""
project_path = "redhat/rhel/rpms/curl"
job_id = "12345"
long_log = "\n".join(f"line {i}" for i in range(1000))

@asynccontextmanager
async def mock_get(url, **kwargs):
async def read():
return long_log.encode("utf-8")

yield flexmock(status=200, read=read, raise_for_status=lambda: None)

flexmock(aiohttp.ClientSession).should_receive("get").replace_with(mock_get)
flexmock(os).should_call("getenv")
flexmock(os).should_receive("getenv").with_args("GITLAB_TOKEN").and_return("test-token").once()

tool = GetPipelineJobLogTool()
result = await tool.run(
input={"project_path": project_path, "job_id": job_id},
)
assert "[... truncated, showing last 500 of 1000 lines ...]" in result.result
assert "line 999" in result.result
assert "line 0" not in result.result