1use 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 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 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 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 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 "(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 }
400}