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