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..a9ba74ff2 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -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" + 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(f"https://gitlab.com/{tool_input.project_path}") + + try: + async with ( + aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session, + 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 + + 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 @@ -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 ( @@ -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: diff --git a/ymir/tools/privileged/tests/unit/test_gitlab.py b/ymir/tools/privileged/tests/unit/test_gitlab.py index 66a26eb8f..35b048aa5 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 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