80f9e01
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<ActionsWorkflow>,
}
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<Vec<WorkflowSummary>> {
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<Vec<LoadedWorkflow>> {
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<LoadedWorkflow> {
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<LoadedWorkflow> {
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<Vec<String>> {
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<String> = entries
.into_iter()
.filter(|entry| {
entry.mode == gitbaby::commit::types::TreeEntryMode::Blob
|| entry.mode == gitbaby::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<String> {
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 != gitbaby::commit::types::TreeEntryMode::Blob
&& entry.mode != gitbaby::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<String> {
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
}