Skip to content

Commit 9241be8

Browse files
committed
Move job handling to a separate module
1 parent 7ca7675 commit 9241be8

File tree

3 files changed

+241
-234
lines changed

3 files changed

+241
-234
lines changed

Diff for: src/ci/citool/src/jobs.rs

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
use crate::{GitHubContext, utils};
2+
use serde_yaml::Value;
3+
use std::collections::BTreeMap;
4+
use std::path::Path;
5+
6+
/// Representation of a job loaded from the `src/ci/github-actions/jobs.yml` file.
7+
#[derive(serde::Deserialize, Debug, Clone)]
8+
pub struct Job {
9+
/// Name of the job, e.g. mingw-check
10+
pub name: String,
11+
/// GitHub runner on which the job should be executed
12+
pub os: String,
13+
pub env: BTreeMap<String, Value>,
14+
/// Should the job be only executed on a specific channel?
15+
#[serde(default)]
16+
pub only_on_channel: Option<String>,
17+
/// Do not cancel the whole workflow if this job fails.
18+
#[serde(default)]
19+
pub continue_on_error: Option<bool>,
20+
/// Free additional disk space in the job, by removing unused packages.
21+
#[serde(default)]
22+
pub free_disk: Option<bool>,
23+
}
24+
25+
impl Job {
26+
/// By default, the Docker image of a job is based on its name.
27+
/// However, it can be overridden by its IMAGE environment variable.
28+
pub fn image(&self) -> String {
29+
self.env
30+
.get("IMAGE")
31+
.map(|v| v.as_str().expect("IMAGE value should be a string").to_string())
32+
.unwrap_or_else(|| self.name.clone())
33+
}
34+
35+
fn is_linux(&self) -> bool {
36+
self.os.contains("ubuntu")
37+
}
38+
}
39+
40+
#[derive(serde::Deserialize, Debug)]
41+
struct JobEnvironments {
42+
#[serde(rename = "pr")]
43+
pr_env: BTreeMap<String, Value>,
44+
#[serde(rename = "try")]
45+
try_env: BTreeMap<String, Value>,
46+
#[serde(rename = "auto")]
47+
auto_env: BTreeMap<String, Value>,
48+
}
49+
50+
#[derive(serde::Deserialize, Debug)]
51+
pub struct JobDatabase {
52+
#[serde(rename = "pr")]
53+
pub pr_jobs: Vec<Job>,
54+
#[serde(rename = "try")]
55+
pub try_jobs: Vec<Job>,
56+
#[serde(rename = "auto")]
57+
pub auto_jobs: Vec<Job>,
58+
59+
/// Shared environments for the individual run types.
60+
envs: JobEnvironments,
61+
}
62+
63+
impl JobDatabase {
64+
fn find_auto_job_by_name(&self, name: &str) -> Option<Job> {
65+
self.auto_jobs.iter().find(|j| j.name == name).cloned()
66+
}
67+
}
68+
69+
pub fn load_job_db(path: &Path) -> anyhow::Result<JobDatabase> {
70+
let db = utils::read_to_string(path)?;
71+
let mut db: Value = serde_yaml::from_str(&db)?;
72+
73+
// We need to expand merge keys (<<), because serde_yaml can't deal with them
74+
// `apply_merge` only applies the merge once, so do it a few times to unwrap nested merges.
75+
db.apply_merge()?;
76+
db.apply_merge()?;
77+
78+
let db: JobDatabase = serde_yaml::from_value(db)?;
79+
Ok(db)
80+
}
81+
82+
/// Representation of a job outputted to a GitHub Actions workflow.
83+
#[derive(serde::Serialize, Debug)]
84+
struct GithubActionsJob {
85+
/// The main identifier of the job, used by CI scripts to determine what should be executed.
86+
name: String,
87+
/// Helper label displayed in GitHub Actions interface, containing the job name and a run type
88+
/// prefix (PR/try/auto).
89+
full_name: String,
90+
os: String,
91+
env: BTreeMap<String, serde_json::Value>,
92+
#[serde(skip_serializing_if = "Option::is_none")]
93+
continue_on_error: Option<bool>,
94+
#[serde(skip_serializing_if = "Option::is_none")]
95+
free_disk: Option<bool>,
96+
}
97+
98+
/// Skip CI jobs that are not supposed to be executed on the given `channel`.
99+
fn skip_jobs(jobs: Vec<Job>, channel: &str) -> Vec<Job> {
100+
jobs.into_iter()
101+
.filter(|job| {
102+
job.only_on_channel.is_none() || job.only_on_channel.as_deref() == Some(channel)
103+
})
104+
.collect()
105+
}
106+
107+
/// Type of workflow that is being executed on CI
108+
#[derive(Debug)]
109+
pub enum RunType {
110+
/// Workflows that run after a push to a PR branch
111+
PullRequest,
112+
/// Try run started with @bors try
113+
TryJob { custom_jobs: Option<Vec<String>> },
114+
/// Merge attempt workflow
115+
AutoJob,
116+
}
117+
118+
/// Maximum number of custom try jobs that can be requested in a single
119+
/// `@bors try` request.
120+
const MAX_TRY_JOBS_COUNT: usize = 20;
121+
122+
fn calculate_jobs(
123+
run_type: &RunType,
124+
db: &JobDatabase,
125+
channel: &str,
126+
) -> anyhow::Result<Vec<GithubActionsJob>> {
127+
let (jobs, prefix, base_env) = match run_type {
128+
RunType::PullRequest => (db.pr_jobs.clone(), "PR", &db.envs.pr_env),
129+
RunType::TryJob { custom_jobs } => {
130+
let jobs = if let Some(custom_jobs) = custom_jobs {
131+
if custom_jobs.len() > MAX_TRY_JOBS_COUNT {
132+
return Err(anyhow::anyhow!(
133+
"It is only possible to schedule up to {MAX_TRY_JOBS_COUNT} custom jobs, received {} custom jobs",
134+
custom_jobs.len()
135+
));
136+
}
137+
138+
let mut jobs = vec![];
139+
let mut unknown_jobs = vec![];
140+
for custom_job in custom_jobs {
141+
if let Some(job) = db.find_auto_job_by_name(custom_job) {
142+
jobs.push(job);
143+
} else {
144+
unknown_jobs.push(custom_job.clone());
145+
}
146+
}
147+
if !unknown_jobs.is_empty() {
148+
return Err(anyhow::anyhow!(
149+
"Custom job(s) `{}` not found in auto jobs",
150+
unknown_jobs.join(", ")
151+
));
152+
}
153+
jobs
154+
} else {
155+
db.try_jobs.clone()
156+
};
157+
(jobs, "try", &db.envs.try_env)
158+
}
159+
RunType::AutoJob => (db.auto_jobs.clone(), "auto", &db.envs.auto_env),
160+
};
161+
let jobs = skip_jobs(jobs, channel);
162+
let jobs = jobs
163+
.into_iter()
164+
.map(|job| {
165+
let mut env: BTreeMap<String, serde_json::Value> = crate::yaml_map_to_json(base_env);
166+
env.extend(crate::yaml_map_to_json(&job.env));
167+
let full_name = format!("{prefix} - {}", job.name);
168+
169+
GithubActionsJob {
170+
name: job.name,
171+
full_name,
172+
os: job.os,
173+
env,
174+
continue_on_error: job.continue_on_error,
175+
free_disk: job.free_disk,
176+
}
177+
})
178+
.collect();
179+
180+
Ok(jobs)
181+
}
182+
183+
pub fn calculate_job_matrix(
184+
db: JobDatabase,
185+
gh_ctx: GitHubContext,
186+
channel: &str,
187+
) -> anyhow::Result<()> {
188+
let run_type = gh_ctx.get_run_type().ok_or_else(|| {
189+
anyhow::anyhow!("Cannot determine the type of workflow that is being executed")
190+
})?;
191+
eprintln!("Run type: {run_type:?}");
192+
193+
let jobs = calculate_jobs(&run_type, &db, channel)?;
194+
if jobs.is_empty() {
195+
return Err(anyhow::anyhow!("Computed job list is empty"));
196+
}
197+
198+
let run_type = match run_type {
199+
RunType::PullRequest => "pr",
200+
RunType::TryJob { .. } => "try",
201+
RunType::AutoJob => "auto",
202+
};
203+
204+
eprintln!("Output");
205+
eprintln!("jobs={jobs:?}");
206+
eprintln!("run_type={run_type}");
207+
println!("jobs={}", serde_json::to_string(&jobs)?);
208+
println!("run_type={run_type}");
209+
210+
Ok(())
211+
}
212+
213+
pub fn find_linux_job<'a>(jobs: &'a [Job], name: &str) -> anyhow::Result<&'a Job> {
214+
let Some(job) = jobs.iter().find(|j| j.name == name) else {
215+
let available_jobs: Vec<&Job> = jobs.iter().filter(|j| j.is_linux()).collect();
216+
let mut available_jobs =
217+
available_jobs.iter().map(|j| j.name.to_string()).collect::<Vec<_>>();
218+
available_jobs.sort();
219+
return Err(anyhow::anyhow!(
220+
"Job {name} not found. The following jobs are available:\n{}",
221+
available_jobs.join(", ")
222+
));
223+
};
224+
if !job.is_linux() {
225+
return Err(anyhow::anyhow!("Only Linux jobs can be executed locally"));
226+
}
227+
228+
Ok(job)
229+
}

0 commit comments

Comments
 (0)