gx 0.8.4

CLI to manage Github Actions dependencies
Documentation
use crate::config::Level;
use crate::domain::file::parsed::{ParsedWorkflow, Trigger};
use crate::lint::{Context, Diagnostic, Rule, RuleName};

/// `missing-concurrency` rule: warns when a workflow triggered by `push:` or `schedule:`
/// has no top-level `concurrency:` block. Without one, two runs racing on the same ref
/// can overwrite each other (registry pushes, tag creation, deploys).
pub struct MissingConcurrencyRule;

impl MissingConcurrencyRule {
    /// Returns a diagnostic when a push/schedule workflow lacks a `concurrency:` block.
    pub fn check_workflow(workflow: &ParsedWorkflow) -> Option<Diagnostic> {
        let race_trigger = workflow.on.iter().find_map(|t| match t {
            Trigger::Push => Some("push"),
            Trigger::Schedule => Some("schedule"),
            Trigger::PullRequest
            | Trigger::PullRequestTarget
            | Trigger::WorkflowDispatch
            | Trigger::WorkflowCall
            | Trigger::WorkflowRun
            | Trigger::Release
            | Trigger::Tags
            | Trigger::Other(_) => None,
        })?;
        if workflow.concurrency.is_some() {
            return None;
        }
        let msg = format!(
            "workflow triggered by `{race_trigger}` has no `concurrency:` block — add one (e.g. `concurrency: {{ group: ${{{{ github.workflow }}}}-${{{{ github.ref }}}}, cancel-in-progress: true }}`) to serialize racing runs"
        );
        Some(
            Diagnostic::new(RuleName::MissingConcurrency, Level::Warn, msg)
                .with_workflow(workflow.path.clone()),
        )
    }
}

impl Rule for MissingConcurrencyRule {
    fn name(&self) -> RuleName {
        RuleName::MissingConcurrency
    }

    fn default_level(&self) -> Level {
        Level::Warn
    }

    fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
        ctx.workflows_full
            .iter()
            .filter_map(Self::check_workflow)
            .collect()
    }
}

#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests use unwrap freely")]
mod tests {
    use super::*;
    use crate::domain::file::parsed::Parsed;
    use crate::domain::file::site::WorkflowPath;

    fn parse(content: &str) -> ParsedWorkflow {
        Parsed::from_yaml(WorkflowPath::new(".github/workflows/x.yml"), content).unwrap()
    }

    #[test]
    fn rule_metadata() {
        let r = MissingConcurrencyRule;
        assert_eq!(r.name(), RuleName::MissingConcurrency);
        assert_eq!(r.default_level(), Level::Warn);
    }

    #[test]
    fn push_without_concurrency_warns() {
        let p = parse("on: push\njobs: {}\n");
        let d = MissingConcurrencyRule::check_workflow(&p).unwrap();
        assert_eq!(d.level, Level::Warn);
        assert!(d.message.contains("push"));
    }

    #[test]
    fn push_with_concurrency_is_clean() {
        let p = parse(
            "on: push
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
jobs: {}
",
        );
        assert!(MissingConcurrencyRule::check_workflow(&p).is_none());
    }

    #[test]
    fn pull_request_without_concurrency_is_clean() {
        // Rule applies only to push/schedule.
        let p = parse("on: pull_request\njobs: {}\n");
        assert!(MissingConcurrencyRule::check_workflow(&p).is_none());
    }

    #[test]
    fn schedule_without_concurrency_warns() {
        let p = parse(
            "on:
  schedule:
    - cron: '0 0 * * *'
jobs: {}
",
        );
        let d = MissingConcurrencyRule::check_workflow(&p).unwrap();
        assert!(d.message.contains("schedule"));
    }
}