Skip to content

Commit b646e5f

Browse files
ljavorsknforro
andauthored
Add retry_job and get_failed_jobs functions to Gitlab MCP (#349)
* Add a retry_job function to retry specific failed jobs from pipeline * Add get_failed_jobs function to get all failed jobs from MR * Use a custom model for the failed pipeline jobs --------- Co-authored-by: Nikola Forró <[email protected]>
1 parent 0bcfc57 commit b646e5f

File tree

3 files changed

+209
-0
lines changed

3 files changed

+209
-0
lines changed

common/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,20 @@ class CachedMRMetadata(BaseModel):
275275
title: str = Field(description="Merge request title")
276276
package: str = Field(description="Package name")
277277
details: str = Field(description="Operation-specific identifier (list of upstream patch URLs for backport, version for rebase)")
278+
279+
280+
# ============================================================================
281+
# GitLab Failed Pipeline Jobs Schemas
282+
# ============================================================================
283+
284+
class FailedPipelineJob(BaseModel):
285+
"""Represents a failed job in a GitLab pipeline."""
286+
287+
id: str = Field(description="Pipeline job ID as a string")
288+
name: str = Field(description="Name of the job")
289+
url: str = Field(description="Full URL to the job in GitLab")
290+
status: str = Field(description="Job status")
291+
stage: str = Field(description="Pipeline stage the job belongs to")
292+
artifacts_url: str = Field(
293+
description="URL to browse job artifacts, empty string if no artifacts"
294+
)

mcp_server/gitlab_tools.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ogr.services.gitlab.pull_request import GitlabPullRequest
1313
from pydantic import Field
1414

15+
from common.models import FailedPipelineJob
1516
from common.validators import AbsolutePath
1617
from utils import clean_stale_repositories
1718

@@ -289,3 +290,80 @@ async def create_merge_request_checklist(
289290
return f"Successfully created checklist for merge request {merge_request_url}"
290291
except Exception as e:
291292
raise ToolError(f"Failed to create checklist for merge request: {e}")
293+
294+
295+
async def retry_pipeline_job(
296+
project_url: Annotated[str, Field(description="GitLab project URL")],
297+
job_id: Annotated[int, Field(description="Job ID to retry")],
298+
) -> str:
299+
"""
300+
Retries a specific job in a GitLab pipeline.
301+
"""
302+
try:
303+
project = await asyncio.to_thread(
304+
get_project, url=project_url, token=os.getenv("GITLAB_TOKEN")
305+
)
306+
307+
def retry_gitlab_job():
308+
job = project.gitlab_repo.jobs.get(job_id)
309+
job.retry()
310+
return job
311+
312+
job = await asyncio.to_thread(retry_gitlab_job)
313+
314+
logger.info(f"Successfully retried job {job_id} for project {project_url}")
315+
return f"Successfully retried job {job_id}. Status: {job.status}"
316+
317+
except Exception as e:
318+
logger.error(f"Failed to retry job {job_id} for project {project_url}: {e}")
319+
raise ToolError(f"Failed to retry job: {e}") from e
320+
321+
322+
async def get_failed_pipeline_jobs_from_merge_request(
323+
merge_request_url: Annotated[str, Field(description="URL of the merge request")],
324+
) -> list[FailedPipelineJob]:
325+
"""
326+
Gets the failed pipeline jobs from the latest pipeline of a merge request.
327+
Returns a list of failed pipeline jobs with their details.
328+
"""
329+
try:
330+
mr = await _get_merge_request_from_url(merge_request_url)
331+
332+
def get_latest_pipeline_jobs():
333+
# Use head_pipeline to get the latest pipeline for this MR
334+
if not hasattr(mr._raw_pr, "head_pipeline") or not mr._raw_pr.head_pipeline:
335+
return []
336+
337+
pipeline_id = mr._raw_pr.head_pipeline["id"]
338+
pipeline = mr.target_project.gitlab_repo.pipelines.get(pipeline_id)
339+
jobs = pipeline.jobs.list(get_all=True)
340+
341+
namespace = mr.target_project.namespace
342+
repo = mr.target_project.repo
343+
failed_jobs = [
344+
FailedPipelineJob(
345+
id=str(job.id),
346+
name=job.name,
347+
url=f"https://gitlab.com/{namespace}/{repo}/-/jobs/{job.id}",
348+
status=job.status,
349+
stage=job.stage,
350+
artifacts_url=(
351+
f"https://gitlab.com/{namespace}/{repo}/-/jobs/{job.id}/artifacts/browse"
352+
if hasattr(job, "artifacts_file") and job.artifacts_file
353+
else ""
354+
),
355+
)
356+
for job in jobs
357+
if job.status == "failed"
358+
]
359+
360+
return failed_jobs
361+
362+
failed_jobs = await asyncio.to_thread(get_latest_pipeline_jobs)
363+
364+
logger.info(f"Found {len(failed_jobs)} failed jobs in latest pipeline for MR {merge_request_url}")
365+
return failed_jobs
366+
367+
except Exception as e:
368+
logger.error(f"Failed to get failed jobs from MR {merge_request_url}: {e}")
369+
raise ToolError(f"Failed to get failed jobs from merge request: {e}") from e

mcp_server/tests/unit/test_gitlab_tools.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
push_to_remote_repository,
2121
add_merge_request_labels,
2222
add_blocking_merge_request_comment,
23+
retry_pipeline_job,
24+
get_failed_pipeline_jobs_from_merge_request,
2325
)
2426
from test_utils import mock_git_repo_basepath
2527

@@ -295,3 +297,115 @@ async def test_create_merge_request_checklist():
295297
)
296298

297299
assert result == f"Successfully created checklist for merge request {merge_request_url}"
300+
301+
302+
@pytest.mark.asyncio
303+
async def test_retry_pipeline_job():
304+
project_url = "https://gitlab.com/redhat/rhel/rpms/bash"
305+
job_id = 12345678
306+
307+
flexmock(GitlabService).should_receive("get_project_from_url").with_args(
308+
url=project_url
309+
).and_return(
310+
flexmock(
311+
gitlab_repo=flexmock(
312+
jobs=flexmock().should_receive("get").with_args(job_id).and_return(
313+
flexmock(id=job_id, status="pending").should_receive("retry").once().mock()
314+
).mock()
315+
)
316+
)
317+
)
318+
319+
result = await retry_pipeline_job(project_url=project_url, job_id=job_id)
320+
321+
assert result == f"Successfully retried job {job_id}. Status: pending"
322+
323+
324+
@pytest.mark.asyncio
325+
async def test_retry_pipeline_job_invalid_project():
326+
project_url = "https://gitlab.com/nonexistent/project"
327+
job_id = 12345678
328+
329+
flexmock(GitlabService).should_receive("get_project_from_url").with_args(
330+
url=project_url
331+
).and_raise(Exception("Project not found"))
332+
333+
with pytest.raises(Exception) as exc_info:
334+
await retry_pipeline_job(project_url=project_url, job_id=job_id)
335+
336+
assert "Failed to retry job" in str(exc_info.value)
337+
338+
339+
@pytest.mark.asyncio
340+
async def test_get_failed_pipeline_jobs_from_merge_request():
341+
merge_request_url = "https://gitlab.com/redhat/centos-stream/rpms/bash/-/merge_requests/123"
342+
pipeline_id = 789
343+
344+
flexmock(GitlabService).should_receive("get_project_from_url").with_args(
345+
url="https://gitlab.com/redhat/centos-stream/rpms/bash"
346+
).and_return(
347+
flexmock().should_receive("get_pr").with_args(123).and_return(
348+
flexmock(
349+
_raw_pr=flexmock(head_pipeline={"id": pipeline_id}),
350+
target_project=flexmock(
351+
namespace="redhat/centos-stream/rpms",
352+
repo="bash",
353+
gitlab_repo=flexmock(
354+
pipelines=flexmock().should_receive("get").with_args(pipeline_id).and_return(
355+
flexmock(
356+
jobs=flexmock().should_receive("list").with_args(get_all=True).and_return([
357+
flexmock(id=11111, name="check-tickets", status="failed", stage="build", artifacts_file={"filename": "debug.log"}),
358+
flexmock(id=22222, name="build_rpm", status="failed", stage="test", artifacts_file=None),
359+
flexmock(id=33333, name="trigger_tests", status="success", stage="test", artifacts_file=None),
360+
]).mock()
361+
)
362+
).mock()
363+
)
364+
),
365+
)
366+
).mock()
367+
)
368+
369+
result = await get_failed_pipeline_jobs_from_merge_request(merge_request_url=merge_request_url)
370+
371+
assert len(result) == 2
372+
assert result[0].id == "11111"
373+
assert result[0].name == "check-tickets"
374+
assert result[0].status == "failed"
375+
assert result[0].stage == "build"
376+
assert "/-/jobs/11111" in result[0].url
377+
assert result[0].artifacts_url == "https://gitlab.com/redhat/centos-stream/rpms/bash/-/jobs/11111/artifacts/browse"
378+
379+
assert result[1].id == "22222"
380+
assert result[1].name == "build_rpm"
381+
assert result[1].status == "failed"
382+
assert result[1].stage == "test"
383+
assert "/-/jobs/22222" in result[1].url
384+
assert result[1].artifacts_url == ""
385+
386+
387+
@pytest.mark.asyncio
388+
async def test_get_failed_pipeline_jobs_from_merge_request_no_pipelines():
389+
merge_request_url = "https://gitlab.com/redhat/centos-stream/rpms/bash/-/merge_requests/123"
390+
391+
flexmock(GitlabService).should_receive("get_project_from_url").with_args(
392+
url="https://gitlab.com/redhat/centos-stream/rpms/bash"
393+
).and_return(
394+
flexmock().should_receive("get_pr").with_args(123).and_return(
395+
flexmock(_raw_pr=flexmock(head_pipeline=None))
396+
).mock()
397+
)
398+
399+
result = await get_failed_pipeline_jobs_from_merge_request(merge_request_url=merge_request_url)
400+
401+
assert len(result) == 0
402+
403+
404+
@pytest.mark.asyncio
405+
async def test_get_failed_pipeline_jobs_from_merge_request_invalid_url():
406+
merge_request_url = "https://github.com/user/repo/pull/123"
407+
408+
with pytest.raises(Exception) as exc_info:
409+
await get_failed_pipeline_jobs_from_merge_request(merge_request_url=merge_request_url)
410+
411+
assert "Could not parse merge request URL" in str(exc_info.value)

0 commit comments

Comments
 (0)