• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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