forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.4 KiB
1//! Continuous integration on the forge side: schedules runs for pushes, tags, pull requests and
2//! manual requests (`schedule`), reaps jobs whose runner went silent (`reaper`), and mirrors job
3//! states into commit statuses (`sync_status`). The workflow file is `.forge/ci.toml`.
4
5use std::path::{Path, PathBuf};
6
7use forge_core::Repo;
8use forge_core::ci::{CiJob, CiRun, JobState, RunState};
9use forge_core::store::commit_status::StatusState;
10
11use crate::state::AppState;
12
13pub mod api;
14pub mod reaper;
15pub mod schedule;
16
17pub use schedule::{manual_run, on_pull_event, on_ref_update};
18
19/// Longest status description written, in characters. Longer text is cut.
20const MAX_DESCRIPTION: usize = 140;
21
22/// Where the log of job `job_id` is kept: `<data_dir>/ci/logs/<job_id>.log`.
23pub fn log_path(data_dir: &Path, job_id: &str) -> PathBuf {
24 data_dir
25 .join("ci")
26 .join("logs")
27 .join(format!("{job_id}.log"))
28}
29
30/// The blob store key of artifact `artifact_id`.
31pub fn artifact_key(artifact_id: &str) -> String {
32 format!("ci/{artifact_id}")
33}
34
35/// Sets the `ci/<job>` commit status of `job` to match its state. A failed write is logged and
36/// never fails the caller.
37pub async fn sync_status(state: &AppState, run: &CiRun, job: &CiJob) {
38 let repo = match state.store.repo_by_id(&run.repo_id).await {
39 Ok(Some(repo)) => repo,
40 Ok(None) => return,
41 Err(e) => {
42 tracing::error!(error = %e, run_id = %run.id, "ci: loading repo for status failed");
43 return;
44 }
45 };
46 let (status, description) = status_for(run, job);
47 let url = format!(
48 "{}/{}/{}/ci/jobs/{}",
49 state.config.base_url.trim_end_matches('/'),
50 repo.owner_name,
51 repo.name,
52 job.id
53 );
54 publish(
55 state,
56 &repo,
57 run,
58 &format!("ci/{}", job.name),
59 status,
60 &description,
61 &url,
62 )
63 .await;
64}
65
66/// Syncs the status of every job of `run`. Returns whether any job is Queued, so the caller knows
67/// to wake the runner claims.
68pub async fn sync_run_statuses(state: &AppState, run: &CiRun) -> forge_core::Result<bool> {
69 let jobs = state.store.jobs_for_run(&run.id).await?;
70 let mut queued = false;
71 for job in &jobs {
72 sync_status(state, run, job).await;
73 queued |= matches!(job.state, JobState::Queued);
74 }
75 Ok(queued)
76}
77
78/// Writes one commit status for `run`. The creator is the run's actor, or the repo owner for a
79/// system run (the status table references users).
80pub(crate) async fn publish(
81 state: &AppState,
82 repo: &Repo,
83 run: &CiRun,
84 context: &str,
85 status: StatusState,
86 description: &str,
87 target_url: &str,
88) {
89 let Some(creator) = status_creator(state, repo, &run.actor_id).await else {
90 tracing::error!(run_id = %run.id, context, "ci: no person to record the status for");
91 return;
92 };
93 let description = short(description);
94 if let Err(e) = state
95 .store
96 .set_status(
97 &repo.id,
98 &run.commit_oid,
99 context,
100 status,
101 Some(&description),
102 Some(target_url),
103 &creator,
104 )
105 .await
106 {
107 tracing::error!(error = %e, run_id = %run.id, context, "ci: writing commit status failed");
108 }
109}
110
111/// The person a CI status is recorded for: the run's actor, or the repo owner for a system run.
112/// An organization never acts, so an org owner (or a missing user) falls back to the ghost
113/// account. `None` only when even the ghost account cannot be read or created.
114async fn status_creator(state: &AppState, repo: &Repo, actor_id: &str) -> Option<String> {
115 let candidate = if actor_id.is_empty() {
116 repo.owner_id.clone()
117 } else {
118 actor_id.to_owned()
119 };
120 match state.store.user_by_id(&candidate).await {
121 Ok(Some(user)) if !user.is_org() => Some(candidate),
122 _ => state
123 .store
124 .ensure_ghost_user()
125 .await
126 .ok()
127 .map(|ghost| ghost.id),
128 }
129}
130
131/// The commit status state and description for a job.
132fn status_for(run: &CiRun, job: &CiJob) -> (StatusState, String) {
133 let message = |fallback: &str| job.message.clone().unwrap_or_else(|| fallback.to_owned());
134 match job.state {
135 JobState::Blocked => {
136 let why = if matches!(run.state, RunState::WaitingApproval) {
137 "Waiting for approval"
138 } else {
139 "Waiting for other jobs"
140 };
141 (StatusState::Pending, why.to_owned())
142 }
143 JobState::Queued => (StatusState::Pending, "Queued".to_owned()),
144 JobState::Running => (StatusState::Pending, "Running".to_owned()),
145 JobState::Success => (StatusState::Success, "Succeeded".to_owned()),
146 JobState::Failure => (StatusState::Failure, "Failed".to_owned()),
147 JobState::Cancelled => (StatusState::Error, message("Cancelled")),
148 JobState::Error => (StatusState::Error, message("Errored")),
149 JobState::Skipped => (StatusState::Error, "skipped".to_owned()),
150 }
151}
152
153/// The first line of `text`, cut to `MAX_DESCRIPTION` characters.
154fn short(text: &str) -> String {
155 text.lines()
156 .next()
157 .unwrap_or_default()
158 .chars()
159 .take(MAX_DESCRIPTION)
160 .collect()
161}
162
163#[cfg(test)]
164mod org_tests {
165 use super::*;
166 use forge_core::Visibility;
167
168 /// A status is recorded for a person. An org actor, or an org owner of a system run, falls
169 /// back to the ghost account.
170 #[tokio::test]
171 async fn an_org_never_is_the_status_creator() {
172 let dir = tempfile::tempdir().unwrap();
173 let state = crate::test_support::test_state(dir.path()).await;
174 let alice = state
175 .store
176 .create_user("alice", None, "h", false)
177 .await
178 .unwrap();
179 let org = state.store.create_org("acme", &alice.id).await.unwrap();
180 let repo = state
181 .store
182 .create_repo(&org.id, "proj", "", Visibility::Private, "main")
183 .await
184 .unwrap();
185 let ghost = state.store.ensure_ghost_user().await.unwrap();
186
187 assert_eq!(
188 status_creator(&state, &repo, &alice.id).await,
189 Some(alice.id.clone())
190 );
191 assert_eq!(
192 status_creator(&state, &repo, &org.id).await,
193 Some(ghost.id.clone())
194 );
195 assert_eq!(status_creator(&state, &repo, "").await, Some(ghost.id));
196 }
197}