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 repo_path = rt.read(repo_path);
94                    let gh_cli = rt.read(gh_cli);
95                    let gh_run_status = match gh_run_status {
96                        GhRunStatus::Completed => "completed",
97                        GhRunStatus::Success => "success",
98                    };
99
100                    rt.sh.change_dir(repo_path);
101
102                    let handle_output = |output: Result<String, xshell::Error>, error_msg: &str| -> Option<String> {
103                        match output {
104                            Ok(output) if output.trim().is_empty() => None,
105                            Ok(output) => Some(output.trim().to_string()),
106                            Err(e) => {
107                                println!("{}: {}", error_msg, e);
108                                None
109                            }
110                        }
111                    };
112
113                    // Get action id for a specific commit
114                    let get_action_id_for_commit = |commit: &str| -> Option<String> {
115                        let output = flowey::shell_cmd!(
116                            rt,
117                            "{gh_cli} run list
118                            --commit {commit}
119                            -w {pipeline_name}
120                            -s {gh_run_status}
121                            -L 1
122                            --json databaseId
123                            --jq .[].databaseId"
124                        )
125                        .read();
126
127                        handle_output(output, &format!("Failed to get action id for commit {}", commit))
128                    };
129
130                    // Verify a job with a given name and status exists for an action id
131                    let verify_job_exists = |action_id: &str, job_name: &str| -> Option<String> {
132                        // cmd! will escape quotes in any strings passed as an arg. Since we need multiple layers of
133                        // escapes, first create the jq filter and then let cmd! handle the escaping.
134                        let select = format!(".jobs[] | select(.name == \"{job_name}\" and .conclusion == \"success\") | .url");
135                        let output = flowey::shell_cmd!(
136                            rt,
137                            "{gh_cli} run view {action_id}
138                            --json jobs
139                            --jq={select}"
140                        )
141                        .read();
142
143                        handle_output(output, &format!("Failed to get job {} for action id {}", job_name, action_id))
144                    };
145
146                    // Closure to get action id for a commit, with optional job verification
147                    let get_action_id = |commit: String| -> Option<String> {
148                        let action_id = get_action_id_for_commit(&commit)?;
149
150                        // If a specific job name is required, verify the job exists with correct status
151                        if let Some(job_name) = &gh_run_job_name {
152                            verify_job_exists(&action_id, job_name)?;
153                        }
154
155                        Some(action_id)
156                    };
157
158                    let mut action_id = get_action_id(github_commit_hash.clone());
159                    let mut loop_count = 0;
160
161                    // CI may not have finished the build for the merge base, so loop through commits
162                    // until we find a finished build or fail after 5 attempts
163                    while action_id.is_none() {
164                        println!(
165                            "Unable to get action id for commit {}, trying again",
166                            github_commit_hash
167                        );
168
169                        if loop_count > 4 {
170                            anyhow::bail!("Failed to get action id after 5 attempts");
171                        }
172
173                        github_commit_hash =
174                            flowey::shell_cmd!(rt, "git rev-parse {github_commit_hash}^").read()?;
175                        action_id = get_action_id(github_commit_hash.clone());
176
177                        loop_count += 1;
178                    }
179
180                    // We have an action id or we would've bailed in the loop above
181                    let id = action_id.context("failed to get action id")?;
182
183                    println!("Got action id {id}, commit {github_commit_hash}");
184                    rt.write(
185                        gh_workflow,
186                        &GithubWorkflow {
187                            id,
188                            commit: github_commit_hash,
189                        },
190                    );
191
192                    Ok(())
193                }
194            });
195        }
196
197        Ok(())
198    }
199}