From ae90d2bc895051b8517bdf9517e48348fc7db89b Mon Sep 17 00:00:00 2001 From: Martin Hoyer Date: Wed, 15 Jul 2026 15:21:38 +0200 Subject: [PATCH 1/2] Add GetPipelineJobLogTool for reading CI job logs Adds MCP tool that fetches raw log output from a GitLab CI job via the /jobs/{id}/trace REST endpoint. Complements GetFailedPipelineJobsFromMergeRequestTool - agents call that first to identify which jobs failed, then this tool to read the actual error output. --- ymir/tools/privileged/gateway.py | 2 + ymir/tools/privileged/gitlab.py | 55 ++++++++++++++++++ .../privileged/tests/unit/test_gitlab.py | 57 +++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/ymir/tools/privileged/gateway.py b/ymir/tools/privileged/gateway.py index b059adb61..e27bb7384 100644 --- a/ymir/tools/privileged/gateway.py +++ b/ymir/tools/privileged/gateway.py @@ -40,6 +40,7 @@ GetInternalRhelBranchesTool, GetMergeRequestDetailsTool, GetPatchFromUrlTool, + GetPipelineJobLogTool, ListProjectMergeRequestsTool, OpenMergeRequestTool, PushToRemoteRepositoryTool, @@ -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), diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index 90df04734..71094d682 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -866,6 +866,61 @@ 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" + 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." + ) + 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(url) + + try: + async with ( + aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session, + aiohttp_get_with_retries(session, url, headers=headers) as response, + ): + response.raise_for_status() + log_text = await response.text() + 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 + + 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 diff --git a/ymir/tools/privileged/tests/unit/test_gitlab.py b/ymir/tools/privileged/tests/unit/test_gitlab.py index 66a26eb8f..51919d269 100644 --- a/ymir/tools/privileged/tests/unit/test_gitlab.py +++ b/ymir/tools/privileged/tests/unit/test_gitlab.py @@ -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 @@ -19,6 +21,7 @@ ForkRepositoryTool, GetAuthorizedCommentsFromMergeRequestTool, GetFailedPipelineJobsFromMergeRequestTool, + GetPipelineJobLogTool, OpenMergeRequestTool, PushToRemoteRepositoryTool, RetryPipelineJobTool, @@ -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 text(): + return log_content + + yield flexmock(status=200, text=text, 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 text(): + return long_log + + yield flexmock(status=200, text=text, 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 From 0c5568071389a4385259896216b7e9f744d57495 Mon Sep 17 00:00:00 2001 From: Martin Hoyer Date: Thu, 16 Jul 2026 17:38:55 +0200 Subject: [PATCH 2/2] fixup! Add GetPipelineJobLogTool for reading CI job logs --- ymir/tools/privileged/gitlab.py | 9 +++++---- ymir/tools/privileged/tests/unit/test_gitlab.py | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index 71094d682..a9ba74ff2 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -897,7 +897,7 @@ async def _run( ) -> 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(url) + headers = _get_auth_headers(f"https://gitlab.com/{tool_input.project_path}") try: async with ( @@ -905,7 +905,8 @@ async def _run( aiohttp_get_with_retries(session, url, headers=headers) as response, ): response.raise_for_status() - log_text = await response.text() + 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}" @@ -1111,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 ( @@ -1172,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: diff --git a/ymir/tools/privileged/tests/unit/test_gitlab.py b/ymir/tools/privileged/tests/unit/test_gitlab.py index 51919d269..35b048aa5 100644 --- a/ymir/tools/privileged/tests/unit/test_gitlab.py +++ b/ymir/tools/privileged/tests/unit/test_gitlab.py @@ -913,10 +913,10 @@ async def test_get_pipeline_job_log(): async def mock_get(url, **kwargs): assert f"/projects/redhat%2Frhel%2Frpms%2Fcurl/jobs/{job_id}/trace" in url - async def text(): - return log_content + async def read(): + return log_content.encode("utf-8") - yield flexmock(status=200, text=text, raise_for_status=lambda: None) + 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") @@ -938,10 +938,10 @@ async def test_get_pipeline_job_log_truncates_large_output(): @asynccontextmanager async def mock_get(url, **kwargs): - async def text(): - return long_log + async def read(): + return long_log.encode("utf-8") - yield flexmock(status=200, text=text, raise_for_status=lambda: None) + 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")