Skip to main content

charon_error/submit/
gitlab_error_report.rs

1use std::sync::{OnceLock, RwLock, RwLockReadGuard};
2use url::{Url, form_urlencoded};
3
4use crate::{
5    ErrorFmt, ErrorFmtLoD, ErrorFmtSettings, ErrorReport, IndentationStyle, LinkDebugIde,
6    ResultExt, StringError, SubmitErrorReport,
7};
8
9/// GitLab implementation of [`SubmitErrorReport`].
10///
11/// Generates pre-filled GitLab issue URLs from error reports. Before use,
12/// call [`setup_global_config`](GitLabErrorReport::setup_global_config) once
13/// at application startup.
14///
15/// # Example
16///
17/// ```rust,no_run
18/// use charon_error::prelude::*;
19/// use charon_error::prelude::gitlab_er::*;
20///
21/// GitLabErrorReport::setup_global_config(GitLabERGlobalSettings {
22///     domain: "gitlab.com".to_owned(),
23///     project_path: "my-group/my-project".to_owned(),
24///     ..Default::default()
25/// }).unwrap_error();
26/// ```
27#[derive(Debug, Clone)]
28pub struct GitLabErrorReport<'a> {
29    /// Reference to the error report being submitted.
30    pub error_report: &'a ErrorReport,
31}
32
33impl<'a> SubmitErrorReport<'a> for GitLabErrorReport<'a> {
34    fn new(error: &'a ErrorReport) -> Self {
35        GitLabErrorReport {
36            error_report: error,
37        }
38    }
39
40    fn get_error_report(&self) -> &ErrorReport {
41        self.error_report
42    }
43
44    fn get_title(&self) -> String {
45        self.error_report.get_last_error_title()
46    }
47
48    fn create_message(&self) -> Result<String, ErrorReport> {
49        Ok(format!(
50            "Panic Info:\n{}\n\
51            Please report this error. Issue title: `{}`\n\
52            \t- Step 1: Check if error report already exists: {}\n\
53            \t- Step 2: Report issue (if it does not already exists):\n\
54            \t- Open Pre-created Report Link: {}",
55            self.create_bug_report()?,
56            self.get_title(),
57            self.check_existing_reports()?,
58            self.create_submit_url()?,
59        ))
60    }
61
62    fn create_bug_report(&self) -> Result<String, ErrorReport> {
63        let settings = Self::get_global_config()?;
64        let display_parts = ReportPartEnabled::display_report();
65
66        self.create_new_bug_report(&settings, &display_parts)
67    }
68
69    fn create_submit_url_limited(&self, max_length: usize) -> Result<Url, ErrorReport> {
70        let settings = Self::get_global_config()?;
71        let url_char_limit = std::cmp::min(max_length, settings.url_char_limit);
72
73        // Get size of smallest report.
74        let minimal_link_parts = ReportPartEnabled::disable_all();
75        let minimal_url = self.create_new_submit_url(&settings, &minimal_link_parts)?;
76        if minimal_url.to_string().len() > url_char_limit {
77            tracing::warn!(
78                limit = url_char_limit,
79                size = minimal_url.to_string().len(),
80                "Minimal URL is already bigger then URL Limit",
81            );
82            return Ok(minimal_url);
83        }
84
85        // Start with all parts enabled (and remove if to long)
86        let mut link_parts = ReportPartEnabled::default();
87
88        // Section 1: All
89        let mut url = self.create_new_submit_url(&settings, &link_parts)?;
90        if url.to_string().len() <= url_char_limit {
91            return Ok(url);
92        } else {
93            // Remove part of message
94            tracing::trace!(
95                url_length = url.to_string().len(),
96                limit = url_char_limit,
97                "URL to Long, Removing 'Steps' from Error Report URL."
98            );
99            link_parts.add_steps = false;
100        }
101
102        // Section 2: All - Steps
103        url = self.create_new_submit_url(&settings, &link_parts)?;
104        if url.to_string().len() <= url_char_limit {
105            return Ok(url);
106        } else {
107            // Remove part of message
108            tracing::trace!(
109                url_length = url.to_string().len(),
110                limit = url_char_limit,
111                "URL to Long, Removing 'Backtrace' from Error Report URL."
112            );
113            link_parts.add_backtrace = false;
114        }
115
116        // Section 3: All - Steps - Backtrace
117        url = self.create_new_submit_url(&settings, &link_parts)?;
118        if url.to_string().len() <= url_char_limit {
119            return Ok(url);
120        }
121
122        // Section 4: All - Steps - Backtrace - Remove Some ErrorFrames
123        let mut error_frame_count = std::cmp::min(20, self.error_report.frames.len() - 1);
124        while error_frame_count > 0 && url.to_string().len() > url_char_limit {
125            link_parts.frame_limit = Some(error_frame_count);
126            url = self.create_new_submit_url(&settings, &link_parts)?;
127            tracing::trace!(
128                url_length = url.to_string().len(),
129                limit = url_char_limit,
130                error_frame_count = error_frame_count,
131                "URL to Long, Removing Some ErrorFrames from Error Report URL."
132            );
133            // URL is small enough
134            if url.to_string().len() <= url_char_limit {
135                return Ok(url);
136            }
137            error_frame_count -= 1;
138        }
139
140        // Section 5: Just display 1 Frame.
141        link_parts.frame_limit = Some(1);
142        let url = self.create_new_submit_url(&settings, &link_parts)?;
143        if url.to_string().len() > url_char_limit {
144            tracing::error!(
145                url_length = url.to_string().len(),
146                limit = url_char_limit,
147                "ErrorReport even adding 1 ErrorFrame to URL will go over URL Limit."
148            );
149        }
150        Ok(url)
151    }
152
153    fn create_submit_url(&self) -> Result<Url, ErrorReport> {
154        self.create_submit_url_limited(usize::MAX)
155    }
156
157    fn check_existing_reports(&self) -> Result<Url, ErrorReport> {
158        let settings = Self::get_global_config()?;
159        Ok(Url::parse(&format!(
160            "https://{domain}/{project_path}/-/issues/?sort=created_date&state=all&in=TITLE&search={title}",
161            domain = settings.domain,
162            project_path = settings.project_path,
163            title = Self::truncate_string(self.get_title(), settings.title_char_limit)
164        ))?)
165    }
166}
167
168#[derive(Debug, Clone)]
169pub struct ReportPartEnabled {
170    pub add_steps: bool,
171    pub add_backtrace: bool,
172    pub add_summary: bool,
173    pub frame_limit: Option<usize>,
174    pub enable_gitlab_syntax: bool,
175}
176
177impl ReportPartEnabled {
178    fn disable_all() -> Self {
179        Self {
180            add_steps: false,
181            add_backtrace: false,
182            add_summary: false,
183            frame_limit: Some(0),
184            enable_gitlab_syntax: true,
185        }
186    }
187
188    fn display_report() -> Self {
189        Self {
190            add_steps: false,
191            add_backtrace: true,
192            add_summary: true,
193            frame_limit: None,
194            enable_gitlab_syntax: false,
195        }
196    }
197}
198
199impl Default for ReportPartEnabled {
200    fn default() -> Self {
201        Self {
202            add_steps: true,
203            add_backtrace: true,
204            add_summary: true,
205            frame_limit: None,
206            enable_gitlab_syntax: true,
207        }
208    }
209}
210
211impl<'a> GitLabErrorReport<'a> {
212    fn gitlab_syntax<S: Into<String> + Default>(text: S, enabled_parts: &ReportPartEnabled) -> S {
213        if enabled_parts.enable_gitlab_syntax {
214            text
215        } else {
216            S::default()
217        }
218    }
219
220    fn create_new_bug_report(
221        &self,
222        settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>,
223        enabled_parts: &ReportPartEnabled,
224    ) -> Result<String, ErrorReport> {
225        let reproduce = if enabled_parts.add_steps {
226            "## Reproduce:\n\
227            Steps to recreate this issue:\n\
228            1. ...\n\
229            2. ...\n\n"
230        } else {
231            ""
232        };
233        let backtrace = if enabled_parts.add_backtrace {
234            let backtrace_string = self
235                .error_report
236                .frames
237                .last()
238                .unwrap_error()
239                .create_backtrace_string();
240            format!(
241                "### Backtrace:\n\
242                {start_summary}\
243                ```\n\
244                {backtrace}\n\
245                ```\n\n\
246                {end_summary}",
247                start_summary = Self::gitlab_syntax(
248                    "<details><summary markdown=\"span\">Backtrace</summary>\n\n",
249                    enabled_parts
250                ),
251                end_summary = Self::gitlab_syntax("</details>\n\n", enabled_parts),
252                backtrace = backtrace_string,
253            )
254        } else {
255            "".to_owned()
256        };
257
258        let summary = if enabled_parts.add_summary {
259            format!(
260                "```hcl\n\
261                {message}\n\
262                ```\n\n",
263                message = self.error_report.stringify(ErrorFmtSettings {
264                    level_of_detail: ErrorFmtLoD::SubmitReport,
265                    frame_limit: enabled_parts.frame_limit,
266                    enable_color: false,
267                    link_format: LinkDebugIde::NoLink,
268                    indentation_style: IndentationStyle::Tab,
269                    skip_first_indentations: 2,
270                    ..Default::default()
271                })?
272            )
273        } else {
274            "".to_owned()
275        };
276
277        Ok(format!(
278            "{header}\
279            ## Summary:\n\
280            {summary}\
281            {reproduce}\
282            ## Debug info:\n\
283            {backtrace}\
284            ### System:\n\
285            * Application version: {version}\n\
286            * System: {os} {arch}\n\
287            {start_footer}\
288            {labels}",
289            header = Self::gitlab_syntax(
290                "<!-- Please check above for similar title.\n\
291                Someone might have already reported this.-->\n",
292                enabled_parts
293            ),
294            summary = summary,
295            reproduce = reproduce,
296            backtrace = backtrace,
297            version = env!("CARGO_PKG_VERSION"),
298            arch = std::env::consts::ARCH,
299            os = std::env::consts::OS,
300            start_footer = Self::gitlab_syntax("<!-- Leave below untouched! -->\n", enabled_parts),
301            labels = Self::gitlab_syntax(self.get_labels(settings), enabled_parts),
302        ))
303    }
304
305    fn create_new_submit_url(
306        &self,
307        settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>,
308        enabled_parts: &ReportPartEnabled,
309    ) -> Result<Url, ErrorReport> {
310        let description = self.create_new_bug_report(settings, enabled_parts)?;
311
312        let encoded: String = form_urlencoded::Serializer::new(String::new())
313            .append_pair(
314                "issue[title]",
315                &Self::truncate_string(self.get_title(), settings.title_char_limit),
316            )
317            .append_pair(
318                "issue[description]",
319                &Self::truncate_string(description, settings.description_char_limit),
320            )
321            .finish();
322
323        // Create URL from encoded message
324        Ok(Url::parse(&format!(
325            "https://{domain}/{project_path}/issues/new?{encoded_message}",
326            domain = settings.domain,
327            project_path = settings.project_path,
328            encoded_message = encoded,
329        ))?)
330    }
331
332    fn get_labels(&self, settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>) -> String {
333        let mut labels_string = "/label".to_owned();
334        for label in &settings.labels {
335            labels_string = format!("{labels_string} ~\"{label}\"");
336        }
337        labels_string
338    }
339
340    /// Initialize the global GitLab configuration. Must be called once at startup.
341    ///
342    /// Returns an error if called more than once.
343    pub fn setup_global_config(config: GitLabERGlobalSettings) -> Result<(), ErrorReport> {
344        GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG
345            .set(RwLock::new(config))
346            .map_err(|_| StringError::new("`GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG` was already set"))
347            .change_context(GlobalSettingsError::AlreadySet)
348    }
349
350    /// Read the global GitLab configuration. Returns an error if not yet set.
351    pub fn get_global_config()
352    -> Result<RwLockReadGuard<'static, GitLabERGlobalSettings>, ErrorReport> {
353        let setting_reader = GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG
354            .get()
355            .ok_or(GlobalSettingsError::SettingNotYetSet)?;
356        // Error does not support `Send` to can not use `change_context()`
357        setting_reader
358            .read()
359            .map_err(StringError::from_error)
360            .change_context(GlobalSettingsError::AcquireReadLockFailed)
361    }
362}
363
364static GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG: OnceLock<RwLock<GitLabERGlobalSettings>> =
365    OnceLock::new();
366
367/// Global configuration for GitLab issue submission.
368///
369/// Set once at application startup via
370/// [`GitLabErrorReport::setup_global_config`].
371#[derive(Debug, Clone)]
372pub struct GitLabERGlobalSettings {
373    /// GitLab domain (e.g. `"gitlab.com"` or `"gitlab.example.com"`).
374    pub domain: String,
375    /// Project path (e.g. `"my-group/my-project"`).
376    pub project_path: String,
377    /// Labels to apply to created issues.
378    pub labels: Vec<String>,
379    /// Limit that will disable parts of the report in order to stay under this limit (if possible)
380    /// Default: 2085
381    pub url_char_limit: usize,
382    /// Hard limit for title, title will be cut off on or before this limit.
383    /// This limit takes into account character boundaries.
384    /// Default: 1024
385    pub title_char_limit: usize,
386    /// Hard limit for description, description will be cut off on or before this limit.
387    /// This limit takes into account character boundaries.
388    /// Default: 1_048_576 (1MB)
389    pub description_char_limit: usize,
390}
391
392impl Default for GitLabERGlobalSettings {
393    fn default() -> Self {
394        Self {
395            domain: "gitlab.com".to_owned(),
396            project_path: "<not set>".to_owned(),
397            labels: vec!["Auto Generated Issue".to_owned()],
398            url_char_limit: 2085,
399            title_char_limit: 1024,
400            description_char_limit: 1_048_576, // 1MB
401        }
402    }
403}
404
405#[derive(thiserror::Error, Debug, Clone)]
406enum GlobalSettingsError {
407    #[error("GitLab Global Settings where already set, this can only be set once.")]
408    AlreadySet,
409    #[error("GitLab Global Settings where not set.")]
410    SettingNotYetSet,
411    #[error("Could not acquire read lock to read GitLab Global Settings.")]
412    AcquireReadLockFailed,
413}