gh_workflow_parser/gh/
util.rs

1use std::{error::Error, process::Command};
2
3use serde::{Deserialize, Serialize};
4
5use crate::gh::gh_cli;
6
7pub fn repo_url_to_job_url(repo_url: &str, run_id: &str, job_id: &str) -> String {
8    let run_url = repo_url_to_run_url(repo_url, run_id);
9    run_url_to_job_url(&run_url, job_id)
10}
11
12pub fn repo_url_to_run_url(repo_url: &str, run_id: &str) -> String {
13    format!("{repo_url}/actions/runs/{run_id}")
14}
15
16pub fn run_url_to_job_url(run_url: &str, job_id: &str) -> String {
17    format!("{run_url}/job/{job_id}")
18}
19
20pub fn run_summary(repo: &str, run_id: &str) -> Result<String, Box<dyn Error>> {
21    let output = Command::new(gh_cli())
22        .arg("run")
23        .arg(format!("--repo={repo}"))
24        .arg("view")
25        .arg(run_id)
26        .output()?;
27
28    assert!(
29        output.status.success(),
30        "Failed to get logs for repo={repo} run_id={run_id}. Failure: {stderr}",
31        stderr = String::from_utf8_lossy(&output.stderr)
32    );
33
34    Ok(String::from_utf8_lossy(&output.stdout).to_string())
35}
36
37pub fn failed_job_log(repo: &str, job_id: &str) -> Result<String, Box<dyn Error>> {
38    let output = Command::new(gh_cli())
39        .arg("run")
40        .arg("view")
41        .arg("--repo")
42        .arg(repo)
43        .arg("--job")
44        .arg(job_id)
45        .arg("--log-failed")
46        .output()?;
47
48    assert!(
49        output.status.success(),
50        "Failed to get logs for job ID: {job_id}. Failure: {stderr}",
51        stderr = String::from_utf8_lossy(&output.stderr)
52    );
53
54    Ok(String::from_utf8_lossy(&output.stdout).to_string())
55}
56
57/// Create an issue in the GitHub repository
58pub fn create_issue(
59    repo: &str,
60    title: &str,
61    body: &str,
62    labels: &[String],
63) -> Result<(), Box<dyn Error>> {
64    // First check if the labels exist on the repository
65    let existing_labels = all_labels(repo)?;
66    for label in labels {
67        if !existing_labels.contains(label) {
68            log::info!("Label {label} does not exist in the repository. Creating it...");
69            create_label(repo, label, "FF0000", "", false)?;
70        } else {
71            log::debug!(
72                "Label {label} already exists in the repository, continuing without creating it."
73            )
74        }
75    }
76    // format the labels into a single string separated by commas
77    let labels = labels.join(",");
78    let mut command = Command::new(gh_cli());
79    command
80        .arg("issue")
81        .arg("create")
82        .arg("--repo")
83        .arg(repo)
84        .arg("--title")
85        .arg(title)
86        .arg("--body")
87        .arg(body)
88        .arg("--label")
89        .arg(labels);
90
91    log::debug!("Debug view of command struct: {command:?}");
92    // Run the command
93    let output = command.output()?;
94
95    assert!(
96        output.status.success(),
97        "Failed to create issue. Failure: {stderr}",
98        stderr = String::from_utf8_lossy(&output.stderr)
99    );
100
101    Ok(())
102}
103
104/// Get the bodies of open issues with a specific label
105pub fn issue_bodies_open_with_label(
106    repo: &str,
107    label: &str,
108) -> Result<Vec<String>, Box<dyn Error>> {
109    let output = Command::new(gh_cli())
110        .arg("issue")
111        .arg("list")
112        .arg("--repo")
113        .arg(repo)
114        .arg("--label")
115        .arg(label)
116        .arg("--json")
117        .arg("body")
118        .output()
119        .expect("Failed to list issues");
120
121    assert!(
122        output.status.success(),
123        "Failed to list issues. Failure: {stderr}",
124        stderr = String::from_utf8_lossy(&output.stderr)
125    );
126
127    let output = String::from_utf8_lossy(&output.stdout);
128
129    /// Helper struct to deserialize a JSON array of github issue bodies
130    #[derive(Serialize, Deserialize)]
131    struct GhIssueBody {
132        pub body: String,
133    }
134
135    let parsed: Vec<GhIssueBody> = serde_json::from_str(&output)?;
136    Ok(parsed.into_iter().map(|item| item.body).collect())
137}
138
139/// Get all labels in a GitHub repository
140pub fn all_labels(repo: &str) -> Result<Vec<String>, Box<dyn Error>> {
141    let output = Command::new(gh_cli())
142        .arg("--repo")
143        .arg(repo)
144        .arg("label")
145        .arg("list")
146        .arg("--json")
147        .arg("name")
148        .output()?;
149
150    assert!(
151        output.status.success(),
152        "Failed to list labels. Failure: {stderr}",
153        stderr = String::from_utf8_lossy(&output.stderr)
154    );
155
156    // Parse the received JSON vector of objects with a `name` field
157    let output = String::from_utf8_lossy(&output.stdout);
158    #[derive(Serialize, Deserialize)]
159    struct Label {
160        name: String,
161    }
162    let parsed: Vec<Label> = serde_json::from_str(&output)?;
163    Ok(parsed.into_iter().map(|label| label.name).collect())
164}
165
166/// Create a label in the GitHub repository
167/// The color should be a 6 character hex code
168/// if `force` is true and the label already exists, it will be overwritten
169pub fn create_label(
170    repo: &str,
171    name: &str,
172    color: &str,
173    description: &str,
174    force: bool,
175) -> Result<(), Box<dyn Error>> {
176    let mut cmd = Command::new(gh_cli());
177    cmd.arg("label")
178        .arg("create")
179        .arg(name)
180        .arg("--repo")
181        .arg(repo)
182        .arg("--color")
183        .arg(color)
184        .arg("--description")
185        .arg(description);
186
187    if force {
188        cmd.arg("--force");
189    }
190
191    let output = cmd.output()?;
192    assert!(
193        output.status.success(),
194        "Failed to create label. Failure: {stderr}",
195        stderr = String::from_utf8_lossy(&output.stderr)
196    );
197
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use pretty_assertions::assert_eq;
205
206    #[test]
207    #[ignore = "This test requires a GitHub repository"]
208    fn test_issue_body_display() {
209        let issue_bodies = issue_bodies_open_with_label(
210            "https://github.com/luftkode/distro-template",
211            "CI scheduled build",
212        )
213        .unwrap();
214        for body in issue_bodies {
215            println!("{body}");
216        }
217    }
218
219    #[test]
220    fn test_parse_json_body() {
221        /// Helper struct to deserialize a JSON array of github issue bodies
222        #[derive(Serialize, Deserialize)]
223        struct GhIssueBody {
224            pub body: String,
225        }
226
227        let data = r#"
228    [
229      {
230        "body": "**Run ID**: 7858139663 [LINK TO RUN](github.com/luftkode/distro-template/actions/runs/7858139663)\\n\\n**1 job failed:**\\n- **`Test template xilinx`**\\n\\n### `Test template xilinx` (ID 21442749267)\\n**Step failed:** `📦 Build yocto image`\\n\\\\n**Log:** github.com/luftkode/distro-template/actions/runs/7858139663/job/21442749267\\n\\\\n*Best effort error summary*:\\n``\\nERROR: sqlite3-native-3_3.43.2-r0 do_fetch: Bitbake Fetcher Error: MalformedUrl('${SOURCE_MIRROR_URL}')\\nERROR: Logfile of failure stored in: /app/yocto/build/tmp/work/x86_64-linux/sqlite3-native/3.43.2/temp/log.do_fetch.21616\\nERROR: Task (virtual:native:/app/yocto/build/../poky/meta/recipes-support/sqlite/sqlite3_3.43.2.bb:do_fetch) failed with exit code '1'\\n\\n2024-02-11 00:09:04 - ERROR    - Command \"/app/yocto/poky/bitbake/bin/bitbake -c build test-template-ci-xilinx-image package-index\" failed with error 1\\n```"
231      },
232      {
233        "body": "Build failed on xilinx. Check the logs at https://github.com/luftkode/distro-template/actions/runs/7858139663 for more details."
234      },
235      {
236        "body": "Build failed on xilinx. Check the logs at https://github.com/luftkode/distro-template/actions/runs/7850874958 for more details."
237      }
238    ]
239    "#;
240
241        // Parse the JSON string to Vec<Item>
242        let parsed: Vec<GhIssueBody> = serde_json::from_str(data).unwrap();
243
244        // Extract the bodies into a Vec<String>
245        let bodies: Vec<String> = parsed.into_iter().map(|item| item.body).collect();
246
247        // Assert that the bodies are as expected
248        assert_eq!(bodies.len(), 3);
249        assert!(bodies[0].contains("**Run ID**: 7858139663 [LINK TO RUN]("));
250        assert_eq!(
251            bodies[1],
252            "Build failed on xilinx. Check the logs at https://github.com/luftkode/distro-template/actions/runs/7858139663 for more details.");
253        assert_eq!(
254            bodies[2],
255            "Build failed on xilinx. Check the logs at https://github.com/luftkode/distro-template/actions/runs/7850874958 for more details.");
256    }
257}