use ci_protocol::actions::{ACTIONS_WORKFLOW_DIR, ActionsWorkflow}; use ci_protocol::dsl::{ PullRequestTrigger, PushTrigger, TriggerSummary, WORKFLOW_DIR, WorkflowDefinition, WorkflowSummary, }; use git::service::GitService; use models::space::spaces; use crate::errors::{CiError, CiResult}; use crate::service::CiService; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoadedWorkflow { pub file: String, pub name: String, pub definition: WorkflowDefinition, pub actions: Option, } const MAX_WORKFLOW_BYTES: u64 = 256 * 1024; impl CiService { fn git_service(&self) -> GitService { GitService::new(self.state.clone()) } pub async fn list_workflows( &self, space: &spaces::Model, repo_name: &str, revision: &str, ) -> CiResult> { let entries = self.workflow_entries(space, repo_name, revision).await?; let mut out = Vec::with_capacity(entries.len()); for entry in entries { let source = match self .read_workflow_file(space, repo_name, revision, &entry) .await { Ok(source) => source, Err(error) => { out.push(WorkflowSummary { file: entry.clone(), name: entry.clone(), valid: false, errors: vec![error.to_string()], triggers: TriggerSummary::default(), inputs: Default::default(), engine: "actions".into(), }); continue; } }; match self.parse_loaded_workflow(&entry, &source).await { Ok(workflow) => out.push(WorkflowSummary { file: entry.clone(), name: workflow.name, valid: true, errors: Vec::new(), triggers: TriggerSummary::from_triggers(&workflow.definition.on), inputs: workflow .actions .as_ref() .map(ActionsWorkflow::inputs) .unwrap_or_default(), engine: "actions".into(), }), Err(error) => out.push(WorkflowSummary { file: entry.clone(), name: file_stem(&entry), valid: false, errors: vec![error.to_string()], triggers: TriggerSummary::default(), inputs: Default::default(), engine: "actions".into(), }), } } Ok(out) } pub async fn load_valid_workflows( &self, space: &spaces::Model, repo_name: &str, revision: &str, ) -> CiResult> { let entries = self.workflow_entries(space, repo_name, revision).await?; let mut out = Vec::new(); for entry in entries { let source = match self .read_workflow_file(space, repo_name, revision, &entry) .await { Ok(source) => source, Err(error) => { tracing::warn!(file = %entry, error = %error, "workflow read failed; skipping"); continue; } }; match self.parse_loaded_workflow(&entry, &source).await { Ok(workflow) => out.push(workflow), Err(error) => { tracing::warn!(file = %entry, error = %error, "workflow parse failed; skipping"); } } } Ok(out) } pub async fn load_workflow_by_file( &self, space: &spaces::Model, repo_name: &str, revision: &str, file: &str, ) -> CiResult { let mut normalized = normalize_workflow_file(file)?; if !file.trim().starts_with(&format!("{WORKFLOW_DIR}/")) && !normalized.starts_with(ACTIONS_WORKFLOW_DIR) { let candidate = format!("{ACTIONS_WORKFLOW_DIR}/{normalized}"); if self .workflow_entries(space, repo_name, revision) .await? .contains(&candidate) { normalized = candidate; } } let source = self .read_workflow_file(space, repo_name, revision, &normalized) .await?; self.parse_loaded_workflow(&normalized, &source).await } async fn parse_loaded_workflow(&self, file: &str, source: &str) -> CiResult { let actions = Some(self.parse_actions_workflow(file, source).await?); let definition = actions.as_ref().unwrap().projection(); Ok(LoadedWorkflow { file: file.to_owned(), name: definition.name.clone().unwrap_or_else(|| file_stem(file)), definition, actions, }) } async fn workflow_entries( &self, space: &spaces::Model, repo_name: &str, revision: &str, ) -> CiResult> { let service = self.git_service(); let mut selected = std::collections::BTreeMap::new(); for directory in [ACTIONS_WORKFLOW_DIR] { let entries = service .tree_read( space.lower_name.clone(), repo_name.to_string(), revision.to_string(), std::path::PathBuf::from(directory), ) .await; let entries = match entries { Ok(entries) => entries, Err(git::share::errors::GitError::NotFound) => continue, Err(error) => return Err(error.into()), }; let prefix = format!("{directory}/"); let mut out: Vec = entries .into_iter() .filter(|entry| { entry.mode == git::rpcgit::commit::types::TreeEntryMode::Blob || entry.mode == git::rpcgit::commit::types::TreeEntryMode::Exec }) .map(|entry| { entry .name .strip_prefix(prefix.as_str()) .unwrap_or(entry.name.as_str()) .to_string() }) .filter(|name| { std::path::Path::new(name).extension().is_some_and(|ext| { ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") }) }) .collect(); out.sort(); out.dedup(); for name in out { selected.insert(name.clone(), format!("{directory}/{name}")); } } Ok(selected.into_values().collect()) } async fn read_workflow_file( &self, space: &spaces::Model, repo_name: &str, revision: &str, file: &str, ) -> CiResult { let service = self.git_service(); let path = if file.starts_with(&format!("{ACTIONS_WORKFLOW_DIR}/")) { file.to_owned() } else { format!("{WORKFLOW_DIR}/{file}") }; let entry = service .tree_resolve_path( space.lower_name.clone(), repo_name.to_string(), revision.to_string(), path.clone(), ) .await .map_err(CiError::from)?; if entry.mode != git::rpcgit::commit::types::TreeEntryMode::Blob && entry.mode != git::rpcgit::commit::types::TreeEntryMode::Exec { return Err(CiError::InvalidArgument(format!( "`{path}` is not a regular file" ))); } let blob = service .blob_content( space.lower_name.clone(), repo_name.to_string(), entry.oid.to_string(), ) .await .map_err(CiError::from)?; if blob.truncated || blob.size as u64 > MAX_WORKFLOW_BYTES { return Err(CiError::InvalidArgument(format!( "`{path}` exceeds the {MAX_WORKFLOW_BYTES} byte workflow limit" ))); } String::from_utf8(blob.data) .map_err(|_| CiError::InvalidArgument(format!("`{path}` is not valid UTF-8"))) } } pub fn normalize_workflow_file(file: &str) -> CiResult { let trimmed = file.trim(); let actions = trimmed.starts_with(&format!("{ACTIONS_WORKFLOW_DIR}/")); let name = trimmed .strip_prefix(&format!("{ACTIONS_WORKFLOW_DIR}/")) .or_else(|| trimmed.strip_prefix(&format!("{WORKFLOW_DIR}/"))) .unwrap_or(trimmed); if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") || name.len() > 200 { return Err(CiError::InvalidArgument( "workflow file must be under .github/workflows/".to_string(), )); } let ext_ok = std::path::Path::new(name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")); if !ext_ok { return Err(CiError::InvalidArgument( "workflow file must end with .yml or .yaml".to_string(), )); } Ok(if actions { format!("{ACTIONS_WORKFLOW_DIR}/{name}") } else { name.to_string() }) } #[must_use] pub fn file_stem(file: &str) -> String { std::path::Path::new(file) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| file.to_string()) } /// Returns whether a push to `branch`/`tag` (or neither) should trigger. #[must_use] pub fn push_trigger_matches( trigger: &PushTrigger, branch: Option<&str>, tag: Option<&str>, ) -> bool { match (branch, tag) { (Some(branch), None) => { trigger.tags.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.branches, branch) } (None, Some(tag)) => { trigger.branches.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.tags, tag) } (None, None) => false, (Some(branch), Some(_)) => { ci_protocol::dsl::matches_patterns(&trigger.branches, branch) && ci_protocol::dsl::matches_patterns(&trigger.tags, tag.unwrap_or_default()) } } } #[must_use] pub fn pull_request_action( action: ci_protocol::events::PullRequestAction, ) -> ci_protocol::dsl::PullRequestAction { match action { ci_protocol::events::PullRequestAction::Opened => { ci_protocol::dsl::PullRequestAction::Opened } ci_protocol::events::PullRequestAction::Synchronize => { ci_protocol::dsl::PullRequestAction::Synchronize } ci_protocol::events::PullRequestAction::Reopened => { ci_protocol::dsl::PullRequestAction::Reopened } ci_protocol::events::PullRequestAction::Merged => { ci_protocol::dsl::PullRequestAction::Merged } ci_protocol::events::PullRequestAction::Closed => { ci_protocol::dsl::PullRequestAction::Closed } } } const DEFAULT_PR_ACTIONS: [ci_protocol::dsl::PullRequestAction; 3] = [ ci_protocol::dsl::PullRequestAction::Opened, ci_protocol::dsl::PullRequestAction::Synchronize, ci_protocol::dsl::PullRequestAction::Reopened, ]; #[must_use] pub fn pull_request_trigger_matches( trigger: &PullRequestTrigger, base_ref: &str, action: ci_protocol::events::PullRequestAction, ) -> bool { let branch_ok = trigger.branches.is_empty() || ci_protocol::dsl::matches_patterns(&trigger.branches, base_ref); let wanted = pull_request_action(action); let action_ok = if trigger.types.is_empty() { DEFAULT_PR_ACTIONS.contains(&wanted) } else { trigger.types.contains(&wanted) }; branch_ok && action_ok }