Skip to main content

flowey_lib_common/
gh_workflow_id.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Gets the Github workflow id for a given commit hash
5
6use flowey::node::prelude::*;
7
8#[derive(Serialize, Deserialize)]
9pub enum GhRunStatus {
10    Completed,
11    Success,
12}
13
14#[derive(Serialize, Deserialize, Clone)]
15pub struct GithubWorkflow {
16    pub id: String,
17    pub commit: String,
18}
19
20#[derive(Serialize, Deserialize)]
21pub enum GitCommitOrBranch {
22    Commit(ReadVar<String>),
23    Branch(ReadVar<String>),
24}
25
26flowey_request! {
27    pub struct Request {
28        /// First component of a github repo path
29        pub repo_owner: String,
30        /// Second component of a github repo path
31        pub repo_name: String,
32        /// Commit hash or branch name
33        pub commit_or_branch: GitCommitOrBranch,
34        /// Pipeline name (the .yaml file)
35        pub pipeline_name: String,
36        /// Require that the run have a certain status
37        pub require_run_status: Option<GhRunStatus>,
38        /// Require that a certain job within the run be successful
39        pub require_successful_job_with_name: Option<String>,
40        /// Output workflow id and associated commit hash
41        pub gh_workflow: WriteVar<GithubWorkflow>,
42    }
43}
44
45new_flow_node!(struct Node);
46
47impl FlowNode for Node {
48    type Request = Request;
49
50    fn imports(ctx: &mut ImportCtx<'_>) {
51        ctx.import::<crate::use_gh_cli::Node>();
52    }
53
54    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
55        for request in requests {
56            let Request {
57                repo_owner,
58                repo_name,
59                commit_or_branch,
60                pipeline_name,
61                require_run_status,
62                require_successful_job_with_name,
63                gh_workflow,
64            } = request;
65
66            let pipeline_name = pipeline_name.clone();
67            let gh_cli = ctx.reqv(crate::use_gh_cli::Request::Get);
68
69            let repo = format!("{repo_owner}/{repo_name}");
70            let commit_hash = match commit_or_branch {
71                GitCommitOrBranch::Commit(commit) => commit,
72                GitCommitOrBranch::Branch(branch) => {
73                    ctx.emit_rust_stepv("get latest commit by branch", |ctx| {
74                        let branch = branch.claim(ctx);
75                        let gh_cli = gh_cli.clone().claim(ctx);
76                        let repo = repo.clone();
77
78                        move |rt| {
79                            let branch = rt.read(branch);
80                            let gh_cli = rt.read(gh_cli);
81
82                            let commit_hash = flowey::shell_cmd!(
83                                rt,
84                                "{gh_cli} api repos/{repo}/commits/{branch} --jq .sha"
85                            )
86                            .read()?
87                            .trim()
88                            .to_string();
89                            Ok(commit_hash)
90                        }
91                    })
92                }
93            };
94
95            ctx.emit_rust_step("get action id by commit", |ctx| {
96                let gh_workflow = gh_workflow.claim(ctx);
97                let commit_hash = commit_hash.claim(ctx);
98                let pipeline_name = pipeline_name.clone();
99                let gh_cli = gh_cli.claim(ctx);
100
101                move |rt| {
102                    let commit_hash = rt.read(commit_hash);
103                    let gh_cli = rt.read(gh_cli);
104
105                    let workflow = get_action_id_by_commit(
106                        rt,
107                        commit_hash,
108                        gh_cli,
109                        repo,
110                        pipeline_name,
111                        require_run_status,
112                        require_successful_job_with_name,
113                    )?;
114
115                    println!("Got action id {}, commit {}", workflow.id, workflow.commit);
116                    rt.write(gh_workflow, &workflow);
117
118                    Ok(())
119                }
120            });
121        }
122
123        Ok(())
124    }
125}
126
127fn get_action_id_by_commit(
128    rt: &mut RustRuntimeServices<'_>,
129    mut commit_hash: String,
130    gh_cli: PathBuf,
131    repo: String,
132    pipeline_name: String,
133    require_run_status: Option<GhRunStatus>,
134    require_successful_job_with_name: Option<String>,
135) -> anyhow::Result<GithubWorkflow> {
136    let (run_status_flag, run_status_value) = require_run_status
137        .map(|s| {
138            (
139                "-s",
140                match s {
141                    GhRunStatus::Completed => "completed",
142                    GhRunStatus::Success => "success",
143                },
144            )
145        })
146        .unzip();
147
148    let handle_output =
149        |output: Result<String, xshell::Error>, error_msg: &str| -> Option<String> {
150            match output {
151                Ok(output) if output.trim().is_empty() => None,
152                Ok(output) => Some(output.trim().to_string()),
153                Err(e) => {
154                    println!("{}: {}", error_msg, e);
155                    None
156                }
157            }
158        };
159
160    // Get action id for a specific commit
161    let get_action_id_for_commit = |commit: &str| -> Option<String> {
162        let output = flowey::shell_cmd!(
163            rt,
164            "{gh_cli} run list
165            -R {repo}
166            --commit {commit}
167            -w {pipeline_name}
168            {run_status_flag...} {run_status_value...}
169            -L 1
170            --json databaseId
171            --jq .[].databaseId"
172        )
173        .read();
174
175        handle_output(
176            output,
177            &format!("Failed to get action id for commit {}", commit),
178        )
179    };
180
181    // Verify a job with a given name and status exists for an action id
182    let verify_job_exists = |action_id: &str, job_name: &str| -> Option<String> {
183        // cmd! will escape quotes in any strings passed as an arg. Since we need multiple layers of
184        // escapes, first create the jq filter and then let cmd! handle the escaping.
185        let select = format!(
186            ".jobs[] | select(.name == \"{job_name}\" and .conclusion == \"success\") | .url"
187        );
188        let output = flowey::shell_cmd!(
189            rt,
190            "{gh_cli} run view {action_id}
191            -R {repo}
192            --json jobs
193            --jq={select}"
194        )
195        .read();
196
197        handle_output(
198            output,
199            &format!("Failed to get job {} for action id {}", job_name, action_id),
200        )
201    };
202
203    // Closure to get action id for a commit, with optional job verification
204    let get_action_id = |commit: &str| -> Option<String> {
205        let action_id = get_action_id_for_commit(commit)?;
206
207        // If a specific job name is required, verify the job exists with correct status
208        if let Some(job_name) = &require_successful_job_with_name {
209            verify_job_exists(&action_id, job_name)?;
210        }
211
212        Some(action_id)
213    };
214
215    let mut action_id = get_action_id(&commit_hash);
216    let mut loop_count = 0;
217
218    // CI may not have finished the build for the merge base, so loop through commits
219    // until we find a finished build or fail after 5 attempts
220    while action_id.is_none() {
221        println!(
222            "Unable to get action id for commit {}, trying again",
223            commit_hash
224        );
225
226        if loop_count > 4 {
227            anyhow::bail!("Failed to get action id after 5 attempts");
228        }
229
230        commit_hash = flowey::shell_cmd!(
231            rt,
232            "{gh_cli} api repos/{repo}/commits/{commit_hash} --jq .parents[0].sha"
233        )
234        .read()?
235        .trim()
236        .to_string();
237        action_id = get_action_id(&commit_hash);
238
239        loop_count += 1;
240    }
241
242    // We have an action id or we would've bailed in the loop above
243    let id = action_id.context("failed to get action id")?;
244
245    Ok(GithubWorkflow {
246        id,
247        commit: commit_hash,
248    })
249}