ci_manager/
issue.rs

1//! Contains the Issue struct and its associated methods.
2//!
3//! The Issue struct is used to represent a GitHub issue that will be created
4//! in a repository. It contains a title, label, and body. The body is a
5//! collection of FailedJob structs, which contain information about the failed
6//! jobs in a GitHub Actions workflow run.
7use crate::{ensure_https_prefix, err_parse::ErrorMessageSummary};
8use anyhow::Ok;
9use std::fmt::{self, Display, Formatter, Write};
10
11pub mod similarity;
12
13#[derive(Debug)]
14pub struct Issue {
15    title: String,
16    labels: Vec<String>,
17    body: IssueBody,
18}
19
20impl Issue {
21    pub fn new(
22        title: String,
23        run_id: String,
24        mut run_link: String,
25        failed_jobs: Vec<FailedJob>,
26        label: String,
27    ) -> Self {
28        let mut labels = vec![label];
29        failed_jobs.iter().for_each(|job| {
30            if let Some(failure_label) = job.failure_label() {
31                if !labels.contains(&failure_label) {
32                    log::debug!("Adding failure label {failure_label} to issue");
33                    labels.push(failure_label);
34                }
35            }
36        });
37        ensure_https_prefix(&mut run_link);
38        Self {
39            title,
40            labels,
41            body: IssueBody::new(run_id, run_link, failed_jobs),
42        }
43    }
44
45    pub fn title(&self) -> &str {
46        self.title.as_str()
47    }
48
49    pub fn labels(&self) -> &[String] {
50        self.labels.as_slice()
51    }
52
53    pub fn body(&mut self) -> String {
54        self.body.to_markdown_string()
55    }
56}
57
58#[derive(Debug)]
59pub struct IssueBody {
60    run_id: String,
61    run_link: String,
62    failed_jobs: Vec<FailedJob>,
63}
64
65impl IssueBody {
66    pub fn new(run_id: String, run_link: String, failed_jobs: Vec<FailedJob>) -> Self {
67        Self {
68            run_id,
69            run_link,
70            failed_jobs,
71        }
72    }
73
74    pub fn to_markdown_string(&mut self) -> String {
75        let mut output_str = format!(
76            "**Run ID**: {id} [LINK TO RUN]({run_url})
77
78**{failed_jobs_list_title}**
79{failed_jobs_name_list}",
80            id = self.run_id,
81            run_url = self.run_link,
82            failed_jobs_list_title = format_args!(
83                "{cnt} {job} failed:",
84                cnt = self.failed_jobs.len(),
85                job = if self.failed_jobs.len() == 1 {
86                    "job"
87                } else {
88                    "jobs"
89                }
90            ),
91            failed_jobs_name_list =
92                self.failed_jobs
93                    .iter()
94                    .fold(String::new(), |mut s_out, job| {
95                        let _ = writeln!(s_out, "- **`{}`**", job.name);
96                        s_out
97                    })
98        );
99        let output_len = output_str.len();
100        let output_left_before_max = 65535 - output_len;
101        assert_ne!(self.failed_jobs.len(), 0);
102        let available_len_per_job = output_left_before_max / self.failed_jobs.len();
103
104        let mut failed_jobs_str = String::new();
105        for job in self.failed_jobs.as_mut_slice() {
106            failed_jobs_str.push_str(job.to_markdown_formatted_limit(available_len_per_job));
107        }
108
109        output_str.push_str(&failed_jobs_str);
110
111        // Final check if it is too long, if it is still too long, we failed to format it properly within the max length
112        // to still create an issue we do a dumb truncate as a last out
113        if output_str.len() > 65535 {
114            let remove_content_len = 65535 - output_str.len();
115            log::warn!("Failed to properly format issue body within content max length, truncating {remove_content_len} characters from the end of the issue body to fit within issue content limits");
116            output_str.truncate(remove_content_len);
117        }
118
119        output_str
120    }
121}
122
123#[derive(Debug, PartialEq)]
124pub enum FirstFailedStep {
125    NoStepsExecuted,
126    StepName(String),
127}
128
129impl fmt::Display for FirstFailedStep {
130    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
131        match self {
132            FirstFailedStep::NoStepsExecuted => write!(f, "No Steps were executed"),
133            FirstFailedStep::StepName(step_name) => write!(f, "{step_name}"),
134        }
135    }
136}
137
138#[derive(Debug)]
139pub struct FailedJob {
140    name: String,
141    id: String,
142    url: String,
143    failed_step: FirstFailedStep,
144    error_message: ErrorMessageSummary,
145    markdown_formatted: Option<String>,
146}
147
148impl FailedJob {
149    pub fn new(
150        name: String,
151        id: String,
152        mut url: String,
153        failed_step: FirstFailedStep,
154        error_message: ErrorMessageSummary,
155    ) -> Self {
156        ensure_https_prefix(&mut url);
157        Self {
158            name,
159            id,
160            url,
161            failed_step,
162            error_message,
163            markdown_formatted: None,
164        }
165    }
166
167    pub fn failure_label(&self) -> Option<String> {
168        self.error_message.failure_label()
169    }
170
171    pub fn markdown_formatted_len(&mut self) -> usize {
172        if let Some(markdown_formatted_str) = self.markdown_formatted.as_deref() {
173            markdown_formatted_str.len()
174        } else {
175            // Format it and then check the length
176            self.to_markdown_formatted().len()
177        }
178    }
179
180    pub fn to_markdown_formatted(&mut self) -> &str {
181        if self.markdown_formatted.is_none() {
182            self.markdown_formatted = Some(self.to_string());
183        }
184        self.markdown_formatted.as_deref().unwrap()
185    }
186
187    pub fn to_markdown_formatted_limit(&mut self, max_len: usize) -> &str {
188        // If the formatting hasn't been done yet or it has been formatted resulting in a larger length than `max_len`, format it again to meet the max_len criteria.
189        if self.markdown_formatted.is_none()
190            || self
191                .markdown_formatted
192                .as_deref()
193                .is_some_and(|md| md.len() > max_len)
194        {
195            let summary = self.error_message.summary();
196            let optional_log = match (self.error_message.logfile_name(), self.error_message.log()) {
197                (Some(name), Some(contents)) => format!(
198                    "
199<details>
200<summary>{name}</summary>
201<br>
202
203```
204{contents}
205```
206
207</details>"
208                ),
209                _ => String::from(""),
210            };
211            let mut formatted_preface_str: String = format!(
212                "
213### `{name}` (ID {id})
214**Step failed:** `{failed_step}`
215\\
216**Log:** {url}",
217                name = self.name,
218                id = self.id,
219                failed_step = self.failed_step,
220                url = self.url,
221            );
222
223            let orig_formatted_err_str = if self.failed_step == FirstFailedStep::NoStepsExecuted {
224                "".to_string()
225            } else {
226                // Only add the `Best effort error summary` text if steps were actually executed
227                formatted_preface_str.push_str(
228                    "
229\\
230*Best effort error summary*:",
231                );
232                format!(
233                    "\n```\n{error_message}```{optional_log}",
234                    error_message = summary,
235                )
236            };
237            let preface_len = formatted_preface_str.len();
238            let formatted_err_str_len = orig_formatted_err_str.len();
239            let mkdown_len = preface_len + formatted_err_str_len;
240            if mkdown_len > max_len {
241                let len_diff = mkdown_len - max_len;
242                let target_formatted_err_str_len = orig_formatted_err_str.len() - len_diff;
243                let error_message = summary.to_string();
244                debug_assert!(error_message.len() >= len_diff);
245                let formatted_err_str = if error_message.len() >= len_diff {
246                    let (_, error_message) = error_message.split_at(len_diff);
247                    let formatted_err_str = format!("\n```\n{error_message}```{optional_log}",);
248                    debug_assert_eq!(formatted_err_str.len(), target_formatted_err_str_len);
249                    formatted_err_str
250                } else {
251                    // Removing the error message is not enough to reach the target max_len so instead we remove the error summary completely
252                    "(content > max len)".to_string()
253                };
254                formatted_preface_str.push_str(&formatted_err_str);
255            } else {
256                formatted_preface_str.push_str(&orig_formatted_err_str);
257            }
258            let final_mkdown = formatted_preface_str;
259            self.markdown_formatted = Some(final_mkdown);
260        }
261
262        self.markdown_formatted.as_deref().unwrap()
263    }
264}
265
266impl Display for FailedJob {
267    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
268        let summary = self.error_message.summary();
269        let optional_log = match (self.error_message.logfile_name(), self.error_message.log()) {
270            (Some(name), Some(contents)) => format!(
271                "
272<details>
273<summary>{name}</summary>
274<br>
275
276```
277{contents}
278```
279</details>"
280            ),
281            _ => String::from(""),
282        };
283
284        write!(
285            f,
286            "
287### `{name}` (ID {id})
288**Step failed:** `{failed_step}`
289\\
290**Log:** {url}
291\\
292*Best effort error summary*:
293```
294{error_message}```{optional_log}",
295            name = self.name,
296            id = self.id,
297            failed_step = self.failed_step,
298            url = self.url,
299            error_message = summary,
300            optional_log = optional_log
301        )
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use pretty_assertions::assert_eq;
309
310    const EXAMPLE_ISSUE_BODY: &str = r#"**Run ID**: 7858139663 [LINK TO RUN]( https://github.com/luftkode/distro-template/actions/runs/7850874958)
311
312**2 jobs failed:**
313- **`Test template xilinx`**
314- **`Test template raspberry`**
315
316### `Test template xilinx` (ID 21442749267)
317**Step failed:** `📦 Build yocto image`
318\
319**Log:** https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749267
320\
321*Best effort error summary*:
322```
323Yocto error: ERROR: No recipes available for: ...
324```
325### `Test template raspberry` (ID 21442749166)
326**Step failed:** `📦 Build yocto image`
327\
328**Log:** https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749166
329\
330*Best effort error summary*:
331```
332Yocto error: ERROR: No recipes available for: ...
333```"#;
334
335    #[test]
336    fn test_issue_new() {
337        let run_id = "7858139663".to_string();
338        let run_link =
339            "https://github.com/luftkode/distro-template/actions/runs/7850874958".to_string();
340        let failed_jobs = vec![
341            FailedJob::new(
342                "Test template xilinx".to_string(),
343                "21442749267".to_string(),
344                "https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749267".to_string(),
345                FirstFailedStep::StepName("📦 Build yocto image".to_owned()),
346                ErrorMessageSummary::Other("Yocto error: ERROR: No recipes available for: ...
347".to_string()),
348            ),
349            FailedJob::new(
350                "Test template raspberry".to_string(),
351                "21442749166".to_string(),
352                "https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749166".to_string(),
353                FirstFailedStep::StepName("📦 Build yocto image".to_owned()),
354                ErrorMessageSummary::Other("Yocto error: ERROR: No recipes available for: ...
355".to_string()),
356            ),
357        ];
358        let label = "bug".to_string();
359        let issue = Issue::new(
360            "Scheduled run failed".to_string(),
361            run_id,
362            run_link,
363            failed_jobs,
364            label,
365        );
366        assert_eq!(issue.title, "Scheduled run failed");
367        assert_eq!(issue.labels, ["bug"]);
368        assert_eq!(issue.body.failed_jobs.len(), 2);
369        assert_eq!(issue.body.failed_jobs[0].id, "21442749267");
370    }
371
372    #[test]
373    fn test_issue_body_display() {
374        let run_id = "7858139663".to_string();
375        let run_link =
376            " https://github.com/luftkode/distro-template/actions/runs/7850874958".to_string();
377        let failed_jobs = vec![
378            FailedJob::new(
379                "Test template xilinx".to_string(),
380                "21442749267".to_string(),
381                "https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749267".to_string(),
382                FirstFailedStep::StepName("📦 Build yocto image".to_owned()),
383                ErrorMessageSummary::Other("Yocto error: ERROR: No recipes available for: ...
384".to_string()),
385            ),
386            FailedJob::new(
387                "Test template raspberry".to_string(),
388                "21442749166".to_string(),
389                "https://github.com/luftkode/distro-template/actions/runs/7850874958/job/21442749166".to_string(),
390                FirstFailedStep::StepName("📦 Build yocto image".to_owned()),
391                ErrorMessageSummary::Other("Yocto error: ERROR: No recipes available for: ...
392".to_string()),
393            ),
394            ];
395
396        let mut issue_body = IssueBody::new(run_id, run_link, failed_jobs);
397        assert_eq!(issue_body.to_markdown_string(), EXAMPLE_ISSUE_BODY);
398        //std::fs::write("test2.md", issue_body.to_markdown_string()).unwrap();
399    }
400}