[Concept,13/16] pickman: Add pipeline helpers to gitlab_api

Message ID 20260222154303.2851319-14-sjg@u-boot.org
State New
Headers
Series pickman: Support monitoring and fixing pipeline failures |

Commit Message

Simon Glass Feb. 22, 2026, 3:42 p.m. UTC
  From: Simon Glass <simon.glass@canonical.com>

Add PickmanMr pipeline_status/pipeline_id fields (extracted from the
head_pipeline attribute), PipelineInfo and FailedJob named tuples, and
get_failed_jobs() which fetches the trace logs of failed jobs from a
GitLab pipeline.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---

 tools/pickman/gitlab_api.py | 77 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 75 insertions(+), 2 deletions(-)
  

Patch

diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py
index 9262ac15a0d..d9635d392ee 100644
--- a/tools/pickman/gitlab_api.py
+++ b/tools/pickman/gitlab_api.py
@@ -42,14 +42,21 @@  class MrCreateError(Exception):
 # Use defaults for new fields so existing code doesn't break
 PickmanMr = namedtuple('PickmanMr', [
     'iid', 'title', 'web_url', 'source_branch', 'description',
-    'has_conflicts', 'needs_rebase'
-], defaults=[False, False])
+    'has_conflicts', 'needs_rebase', 'pipeline_status', 'pipeline_id'
+], defaults=[False, False, None, None])
 
 # Comment info returned by get_mr_comments()
 MrComment = namedtuple('MrComment', [
     'id', 'author', 'body', 'created_at', 'resolvable', 'resolved'
 ])
 
+# Pipeline info
+PipelineInfo = namedtuple('PipelineInfo', ['id', 'status', 'web_url'])
+
+# Failed job info from a pipeline
+FailedJob = namedtuple('FailedJob',
+                        ['id', 'name', 'stage', 'web_url', 'log_tail'])
+
 
 def check_available():
     """Check if the python-gitlab module is available
@@ -320,6 +327,9 @@  def get_pickman_mrs(remote, state='opened'):
 
                 # For open MRs, fetch full details since list() doesn't
                 # include accurate merge status fields
+                pipeline_status = None
+                pipeline_id = None
+
                 if state == 'opened':
                     full_mr = project.mergerequests.get(merge_req.iid)
                     has_conflicts = getattr(full_mr, 'has_conflicts', False)
@@ -333,6 +343,12 @@  def get_pickman_mrs(remote, state='opened'):
                         diverged = getattr(full_mr, 'diverged_commits_count', 0)
                         needs_rebase = diverged and diverged > 0
 
+                    # Extract pipeline info from head_pipeline
+                    head_pipeline = getattr(full_mr, 'head_pipeline', None)
+                    if head_pipeline:
+                        pipeline_status = head_pipeline.get('status')
+                        pipeline_id = head_pipeline.get('id')
+
                 pickman_mrs.append(PickmanMr(
                     iid=merge_req.iid,
                     title=merge_req.title,
@@ -341,6 +357,8 @@  def get_pickman_mrs(remote, state='opened'):
                     description=merge_req.description or '',
                     has_conflicts=has_conflicts,
                     needs_rebase=needs_rebase,
+                    pipeline_status=pipeline_status,
+                    pipeline_id=pipeline_id,
                 ))
         return pickman_mrs
     except gitlab.exceptions.GitlabError as exc:
@@ -423,6 +441,61 @@  def get_mr_comments(remote, mr_iid):
         return None
 
 
+def get_failed_jobs(remote, pipeline_id, max_log_lines=200):
+    """Get failed jobs from a pipeline
+
+    Args:
+        remote (str): Remote name
+        pipeline_id (int): Pipeline ID
+        max_log_lines (int): Maximum log lines to fetch per job
+
+    Returns:
+        list: List of FailedJob tuples, or None on failure
+    """
+    if not check_available():
+        return None
+
+    token = get_token()
+    if not token:
+        tout.error('GITLAB_TOKEN environment variable not set')
+        return None
+
+    remote_url = get_remote_url(remote)
+    host, proj_path = parse_url(remote_url)
+
+    if not host or not proj_path:
+        return None
+
+    try:
+        glab = gitlab.Gitlab(f'https://{host}', private_token=token)
+        project = glab.projects.get(proj_path)
+        pipeline = project.pipelines.get(pipeline_id)
+        jobs = pipeline.jobs.list(scope='failed', get_all=True)
+
+        failed_jobs = []
+        for job in jobs:
+            # Fetch full job to get trace
+            full_job = project.jobs.get(job.id)
+            try:
+                trace = full_job.trace().decode('utf-8', errors='replace')
+                lines = trace.splitlines()
+                log_tail = '\n'.join(lines[-max_log_lines:])
+            except (AttributeError, gitlab.exceptions.GitlabError):
+                log_tail = ''
+
+            failed_jobs.append(FailedJob(
+                id=job.id,
+                name=job.name,
+                stage=job.stage,
+                web_url=job.web_url,
+                log_tail=log_tail,
+            ))
+        return failed_jobs
+    except gitlab.exceptions.GitlabError as exc:
+        tout.error(f'GitLab API error: {exc}')
+        return None
+
+
 def reply_to_mr(remote, mr_iid, message):
     """Post a reply to a merge request