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/// Common parameters for all workflow queries
21#[derive(Serialize, Deserialize)]
22pub struct WorkflowQueryParams {
23    pub github_commit_hash: ReadVar<String>,
24    pub repo_path: ReadVar<PathBuf>,
25    pub pipeline_name: String,
26    pub gh_workflow: WriteVar<GithubWorkflow>,
27}
28
29/// Basic workflow query with default settings
30#[derive(Serialize, Deserialize)]
31pub struct BasicQuery {
32    #[serde(flatten)]
33    pub params: WorkflowQueryParams,
34}
35
36/// Query with custom status and specific job name
37#[derive(Serialize, Deserialize)]
38pub struct QueryWithStatusAndJob {
39    #[serde(flatten)]
40    pub params: WorkflowQueryParams,
41    pub gh_run_status: GhRunStatus,
42    pub gh_run_job_name: String,
43}
44
45flowey_request! {
46    pub enum Request {
47        /// Get workflow ID with default settings (success status)
48        Basic(BasicQuery),
49        /// Get workflow ID with custom status and specific job name
50        WithStatusAndJob(QueryWithStatusAndJob),
51    }
52}
53
54new_flow_node!(struct Node);
55
56impl FlowNode for Node {
57    type Request = Request;
58
59    fn imports(ctx: &mut ImportCtx<'_>) {
60        ctx.import::<crate::use_gh_cli::Node>();
61    }
62
63    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
64        for request in requests {
65            let (params, gh_run_status, gh_run_job_name) = match request {
66                Request::Basic(BasicQuery { params }) => (params, GhRunStatus::Success, None),
67                Request::WithStatusAndJob(QueryWithStatusAndJob {
68                    params,
69                    gh_run_status,
70                    gh_run_job_name,
71                }) => (params, gh_run_status, Some(gh_run_job_name)),
72            };
73
74            let WorkflowQueryParams {
75                github_commit_hash,
76                repo_path,
77                pipeline_name,
78                gh_workflow,
79            } = params;
80
81            let pipeline_name = pipeline_name.clone();
82            let gh_cli = ctx.reqv(crate::use_gh_cli::Request::Get);
83
84            ctx.emit_rust_step("get action id by commit", |ctx| {
85                let gh_workflow = gh_workflow.claim(ctx);
86                let github_commit_hash = github_commit_hash.claim(ctx);
87                let repo_path = repo_path.claim(ctx);
88                let pipeline_name = pipeline_name.clone();
89                let gh_cli = gh_cli.claim(ctx);
90
91                move |rt| {
92                    let mut github_commit_hash = rt.read(github_commit_hash);
93                    let sh = xshell::Shell::new()?;
94                    let repo_path = rt.read(repo_path);
95                    let gh_cli = rt.read(gh_cli);
96                    let gh_run_status = match gh_run_status {
97                        GhRunStatus::Completed => "completed",
98                        GhRunStatus::Success => "success",
99                    };
100
101                    sh.change_dir(repo_path);
102
103                    let handle_output = |output: Result<String, xshell::Error>, error_msg: &str| -> Option<String> {
104                        match output {
105                            Ok(output) if output.trim().is_empty() => None,
106                            Ok(output) => Some(output.trim().to_string()),
107                            Err(e) => {
108                                println!("{}: {}", error_msg, e);
109                                None
110                            }
111                        }
112                    };
113
114                    // Get action id for a specific commit
115                    let get_action_id_for_commit = |commit: &str| -> Option<String> {
116                        let output = xshell::cmd!(
117                            sh,
118                            "{gh_cli} run list
119                            --commit {commit}
120                            -w {pipeline_name}
121                            -s {gh_run_status}
122                            -L 1
123                            --json databaseId
124                            --jq .[].databaseId"
125                        )
126                        .read();
127
128                        handle_output(output, &format!("Failed to get action id for commit {}", commit))
129                    };
130
131                    // Verify a job with a given name and status exists for an action id
132                    let verify_job_exists = |action_id: &str, job_name: &str| -> Option<String> {
133                        // cmd! will escape quotes in any strings passed as an arg. Since we need multiple layers of
134                        // escapes, first create the jq filter and then let cmd! handle the escaping.
135                        let select = format!(".jobs[] | select(.name == \"{job_name}\" and .conclusion == \"success\") | .url");
136                        let output = xshell::cmd!(
137                            sh,
138                            "{gh_cli} run view {action_id}
139                            --json jobs
140                            --jq={select}"
141                        )
142                        .read();
143
144                        handle_output(output, &format!("Failed to get job {} for action id {}", job_name, action_id))
145                    };
146
147                    // Closure to get action id for a commit, with optional job verification
148                    let get_action_id = |commit: String| -> Option<String> {
149                        let action_id = get_action_id_for_commit(&commit)?;
150
151                        // If a specific job name is required, verify the job exists with correct status
152                        if let Some(job_name) = &gh_run_job_name {
153                            verify_job_exists(&action_id, job_name)?;
154                        }
155
156                        Some(action_id)
157                    };
158
159                    let mut action_id = get_action_id(github_commit_hash.clone());
160                    let mut loop_count = 0;
161
162                    // CI may not have finished the build for the merge base, so loop through commits
163                    // until we find a finished build or fail after 5 attempts
164                    while action_id.is_none() {
165                        println!(
166                            "Unable to get action id for commit {}, trying again",
167                            github_commit_hash
168                        );
169
170                        if loop_count > 4 {
171                            anyhow::bail!("Failed to get action id after 5 attempts");
172                        }
173
174                        github_commit_hash =
175                            xshell::cmd!(sh, "git rev-parse {github_commit_hash}^").read()?;
176                        action_id = get_action_id(github_commit_hash.clone());
177
178                        loop_count += 1;
179                    }
180
181                    // We have an action id or we would've bailed in the loop above
182                    let id = action_id.context("failed to get action id")?;
183
184                    println!("Got action id {id}, commit {github_commit_hash}");
185                    rt.write(
186                        gh_workflow,
187                        &GithubWorkflow {
188                            id,
189                            commit: github_commit_hash,
190                        },
191                    );
192
193                    Ok(())
194                }
195            });
196        }
197
198        Ok(())
199    }
200}