1#!/usr/bin/env python3 2# Copyright © 2023 Collabora Ltd. 3# Authors: 4# Helen Koike <helen.koike@collabora.com> 5# 6# For the dependencies, see the requirements.txt 7# SPDX-License-Identifier: MIT 8 9 10import argparse 11import logging as log 12import os 13import re 14import traceback 15from datetime import datetime, timedelta 16from typing import Any, Dict 17 18import gitlab 19import pytz 20from ci_gantt_chart import generate_gantt_chart 21from gitlab import Gitlab 22from gitlab.base import RESTObject 23from gitlab.v4.objects import Project, ProjectPipeline 24from gitlab_common import (GITLAB_URL, get_gitlab_pipeline_from_url, 25 get_token_from_default_dir, read_token) 26 27 28class MockGanttExit(Exception): 29 pass 30 31 32LAST_MARGE_EVENT_FILE = os.path.expanduser("~/.config/last_marge_event") 33 34 35def read_last_event_date_from_file() -> str: 36 try: 37 with open(LAST_MARGE_EVENT_FILE, "r") as f: 38 last_event_date = f.read().strip() 39 except FileNotFoundError: 40 # 3 days ago 41 last_event_date = (datetime.now() - timedelta(days=3)).isoformat() 42 return last_event_date 43 44 45def pretty_time(time_str: str) -> str: 46 """Pretty print time""" 47 local_timezone = datetime.now().astimezone().tzinfo 48 49 time_d = datetime.fromisoformat(time_str.replace("Z", "+00:00")).astimezone( 50 local_timezone 51 ) 52 return f'{time_str} ({time_d.strftime("%d %b %Y %Hh%Mm%Ss")} {local_timezone})' 53 54 55def compose_message(file_name: str, attachment_url: str) -> str: 56 return f""" 57[{file_name}]({attachment_url}) 58 59<details> 60<summary>more info</summary> 61 62This message was generated by the ci_post_gantt.py script, which is running on a server at Collabora. 63</details> 64""" 65 66 67def gitlab_upload_file_get_url(gl: Gitlab, project_id: str, filepath: str) -> str: 68 project: Project = gl.projects.get(project_id) 69 uploaded_file: Dict[str, Any] = project.upload(filepath, filepath=filepath) 70 return uploaded_file["url"] 71 72 73def gitlab_post_reply_to_note(gl: Gitlab, event: RESTObject, reply_message: str): 74 """ 75 Post a reply to a note in thread based on a GitLab event. 76 77 :param gl: The GitLab connection instance. 78 :param event: The event object containing the note details. 79 :param reply_message: The reply message. 80 """ 81 try: 82 note_id = event.target_id 83 merge_request_iid = event.note["noteable_iid"] 84 85 project = gl.projects.get(event.project_id) 86 merge_request = project.mergerequests.get(merge_request_iid) 87 88 # Find the discussion to which the note belongs 89 discussions = merge_request.discussions.list(iterator=True) 90 target_discussion = next( 91 ( 92 d 93 for d in discussions 94 if any(n["id"] == note_id for n in d.attributes["notes"]) 95 ), 96 None, 97 ) 98 99 if target_discussion is None: 100 raise ValueError("Discussion for the note not found.") 101 102 # Add a reply to the discussion 103 reply = target_discussion.notes.create({"body": reply_message}) 104 return reply 105 106 except gitlab.exceptions.GitlabError as e: 107 log.error(f"Failed to post a reply to '{event.note['body']}': {e}") 108 return None 109 110 111def main( 112 token: str | None, 113 since: str | None, 114 marge_user_id: int = 9716, 115 project_ids: list[int] = [176], 116 ci_timeout: float = 60, 117): 118 log.basicConfig(level=log.INFO) 119 if token is None: 120 token = get_token_from_default_dir() 121 122 token = read_token(token) 123 gl = Gitlab(url=GITLAB_URL, private_token=token, retry_transient_errors=True) 124 125 user = gl.users.get(marge_user_id) 126 last_event_at = since if since else read_last_event_date_from_file() 127 128 log.info(f"Retrieving Marge messages since {pretty_time(last_event_at)}\n") 129 130 # the "after" only considers the "2023-10-24" part, it doesn't consider the time 131 events = user.events.list( 132 all=True, 133 target_type="note", 134 after=(datetime.now() - timedelta(days=3)).isoformat(), 135 sort="asc", 136 ) 137 138 last_event_at_date = datetime.fromisoformat( 139 last_event_at.replace("Z", "+00:00") 140 ).replace(tzinfo=pytz.UTC) 141 142 for event in events: 143 if event.project_id not in project_ids: 144 continue 145 created_at_date = datetime.fromisoformat( 146 event.created_at.replace("Z", "+00:00") 147 ).replace(tzinfo=pytz.UTC) 148 if created_at_date <= last_event_at_date: 149 continue 150 last_event_at = event.created_at 151 152 escaped_gitlab_url = re.escape(GITLAB_URL) 153 match = re.search(rf"{escaped_gitlab_url}/[^\s<]+", event.note["body"]) 154 155 if match: 156 try: 157 log.info(f"Found message: {event.note['body']}") 158 pipeline_url = match.group(0)[:-1] 159 pipeline: ProjectPipeline 160 pipeline, _ = get_gitlab_pipeline_from_url(gl, pipeline_url) 161 log.info("Generating gantt chart...") 162 fig = generate_gantt_chart(pipeline, ci_timeout) 163 file_name = f"{str(pipeline.id)}-Gantt.html" 164 fig.write_html(file_name) 165 log.info("Uploading gantt file...") 166 file_url = gitlab_upload_file_get_url(gl, event.project_id, file_name) 167 log.info("Posting reply ...") 168 message = compose_message(file_name, file_url) 169 gitlab_post_reply_to_note(gl, event, message) 170 except MockGanttExit: 171 pass # Allow tests to exit early without printing a traceback 172 except Exception as e: 173 log.info(f"Failed to generate gantt chart, not posting reply.{e}") 174 traceback.print_exc() 175 176 if not since: 177 log.info( 178 f"Updating last event date to {pretty_time(last_event_at)} on {LAST_MARGE_EVENT_FILE}\n" 179 ) 180 with open(LAST_MARGE_EVENT_FILE, "w") as f: 181 f.write(last_event_at) 182 183 184if __name__ == "__main__": 185 parser = argparse.ArgumentParser(description="Monitor rejected pipelines by Marge.") 186 parser.add_argument( 187 "--token", 188 metavar="token", 189 type=str, 190 default=None, 191 help="force GitLab token, otherwise it's read from ~/.config/gitlab-token", 192 ) 193 parser.add_argument( 194 "--since", 195 metavar="since", 196 type=str, 197 default=None, 198 help="consider only events after this date (ISO format), otherwise it's read from ~/.config/last_marge_event", 199 ) 200 parser.add_argument( 201 "--marge-user-id", 202 metavar="marge_user_id", 203 type=int, 204 default=9716, # default https://gitlab.freedesktop.org/users/marge-bot/activity 205 help="GitLab user ID for marge-bot, defaults to 9716", 206 ) 207 parser.add_argument( 208 "--project-id", 209 metavar="project_id", 210 type=int, 211 nargs="+", 212 default=[176], # default is the mesa/mesa project id 213 help="GitLab project id(s) to analyze. Defaults to 176 i.e. mesa/mesa.", 214 ) 215 parser.add_argument( 216 "--ci-timeout", 217 metavar="ci_timeout", 218 type=float, 219 default=60, 220 help="Time that marge-bot will wait for ci to finish. Defaults to one hour.", 221 ) 222 args = parser.parse_args() 223 main(args.token, args.since, args.marge_user_id, args.project_id, args.ci_timeout) 224