runx-core 0.6.19

Pure Rust parity kernel for runx state-machine and policy decisions.
Documentation
use super::{
    PublicCommentOpportunityRequest, PublicCommentPolicyDecision, PublicPolicyDecision,
    PublicPullRequestCandidateRequest, PublicRecentOutcome, PublicWorkPolicy,
    RequiredPublicWorkPolicy,
};

const DEFAULT_BLOCKED_AUTHOR_PATTERNS: &[&str] = &[
    "[bot]",
    "app/",
    "renovate",
    "dependabot",
    "github-actions",
    "github-actions[bot]",
];
const DEFAULT_BLOCKED_HEAD_REF_PREFIXES: &[&str] = &[
    "renovate/",
    "dependabot/",
    "runx/issue-",
    "runx/evidence-projection-derive",
];
const DEFAULT_BLOCKED_EXACT_LABELS: &[&str] = &[
    "dependencies",
    "dependency",
    "deps",
    "rust dependencies",
    "javascript dependencies",
    "python dependencies",
    "artifact drift",
    "artifact-update",
    "artifact update",
    "internal",
];
const DEFAULT_BLOCKED_LABEL_PREFIXES: &[&str] = &["build:", "release:"];
const DEFAULT_TRUST_RECOVERY_STATUSES: &[&str] = &["spam", "minimized", "harmful"];

#[must_use]
pub fn default_public_work_policy() -> RequiredPublicWorkPolicy {
    RequiredPublicWorkPolicy {
        blocked_author_patterns: strings(DEFAULT_BLOCKED_AUTHOR_PATTERNS),
        blocked_head_ref_prefixes: strings(DEFAULT_BLOCKED_HEAD_REF_PREFIXES),
        blocked_exact_labels: strings(DEFAULT_BLOCKED_EXACT_LABELS),
        blocked_label_prefixes: strings(DEFAULT_BLOCKED_LABEL_PREFIXES),
        trust_recovery_statuses: strings(DEFAULT_TRUST_RECOVERY_STATUSES),
        require_welcome_signal_for_pull_request_comments: true,
    }
}

#[must_use]
pub fn normalize_public_work_policy(policy: &PublicWorkPolicy) -> RequiredPublicWorkPolicy {
    let fallback = default_public_work_policy();
    RequiredPublicWorkPolicy {
        blocked_author_patterns: normalize_values(
            policy.blocked_author_patterns.as_deref(),
            &fallback.blocked_author_patterns,
        ),
        blocked_head_ref_prefixes: normalize_values(
            policy.blocked_head_ref_prefixes.as_deref(),
            &fallback.blocked_head_ref_prefixes,
        ),
        blocked_exact_labels: normalize_values(
            policy.blocked_exact_labels.as_deref(),
            &fallback.blocked_exact_labels,
        ),
        blocked_label_prefixes: normalize_values(
            policy.blocked_label_prefixes.as_deref(),
            &fallback.blocked_label_prefixes,
        ),
        trust_recovery_statuses: normalize_values(
            policy.trust_recovery_statuses.as_deref(),
            &fallback.trust_recovery_statuses,
        ),
        require_welcome_signal_for_pull_request_comments: policy
            .require_welcome_signal_for_pull_request_comments
            .unwrap_or(fallback.require_welcome_signal_for_pull_request_comments),
    }
}

#[must_use]
pub fn evaluate_public_pull_request_candidate(
    request: &PublicPullRequestCandidateRequest,
    policy: &PublicWorkPolicy,
) -> PublicPolicyDecision {
    let normalized = normalize_public_work_policy(policy);
    let reasons = pull_request_candidate_reasons(request, &normalized);
    PublicPolicyDecision {
        blocked: !reasons.is_empty(),
        reasons,
    }
}

fn pull_request_candidate_reasons(
    request: &PublicPullRequestCandidateRequest,
    policy: &RequiredPublicWorkPolicy,
) -> Vec<String> {
    let mut reasons = Vec::new();
    if is_blocked_author(request.author_login.as_deref(), policy) {
        reasons.push("bot_authored_pull_request".to_owned());
    }
    if is_dependency_update_pull_request(request, policy) {
        reasons.push("dependency_update_pull_request".to_owned());
    }
    if has_blocked_pull_request_labels(&request.labels, policy) {
        reasons.push("internal_or_build_only_pull_request".to_owned());
    }
    reasons
}

#[must_use]
pub fn evaluate_public_comment_opportunity(
    request: &PublicCommentOpportunityRequest,
    policy: &PublicWorkPolicy,
) -> PublicCommentPolicyDecision {
    let normalized = normalize_public_work_policy(policy);
    let mut reasons = pull_request_candidate_reasons(&request.pull_request, &normalized);
    let welcome_signal = has_welcome_signal(request, &normalized);

    if request.source.as_deref() == Some("github_pull_request")
        && request.lane.as_deref() == Some("issue-triage")
        && normalized.require_welcome_signal_for_pull_request_comments
        && !welcome_signal
    {
        reasons.push("comment_without_welcome_signal".to_owned());
    }
    if request.lane.as_deref() == Some("issue-triage")
        && is_comment_lane_in_trust_recovery(&request.recent_outcomes, &normalized)
    {
        reasons.push("comment_lane_in_trust_recovery".to_owned());
    }

    PublicCommentPolicyDecision {
        blocked: !reasons.is_empty(),
        reasons,
        welcome_signal,
    }
}

fn is_blocked_author(author_login: Option<&str>, policy: &RequiredPublicWorkPolicy) -> bool {
    let login = normalize(author_login.unwrap_or_default());
    !login.is_empty()
        && policy
            .blocked_author_patterns
            .iter()
            .any(|pattern| login.contains(pattern))
}

fn is_dependency_update_pull_request(
    request: &PublicPullRequestCandidateRequest,
    policy: &RequiredPublicWorkPolicy,
) -> bool {
    let normalized_labels = normalize_labels(&request.labels);
    let normalized_title = normalize(request.title.as_deref().unwrap_or_default());
    let normalized_head = normalize(request.head_ref_name.as_deref().unwrap_or_default());
    if policy
        .blocked_head_ref_prefixes
        .iter()
        .any(|prefix| normalized_head.starts_with(prefix))
    {
        return true;
    }
    if normalized_labels
        .iter()
        .any(|label| policy.blocked_exact_labels.contains(label))
    {
        return true;
    }
    if has_update_verb(&normalized_title) && has_version_number(&normalized_title) {
        return true;
    }
    normalized_title.contains("dependency")
        || normalized_title.contains("dependencies")
        || normalized_title.contains("deps")
}

fn has_blocked_pull_request_labels(labels: &[String], policy: &RequiredPublicWorkPolicy) -> bool {
    normalize_labels(labels).iter().any(|label| {
        policy.blocked_exact_labels.contains(label)
            || policy
                .blocked_label_prefixes
                .iter()
                .any(|prefix| label.starts_with(prefix))
    })
}

fn has_welcome_signal(
    request: &PublicCommentOpportunityRequest,
    policy: &RequiredPublicWorkPolicy,
) -> bool {
    if !policy.require_welcome_signal_for_pull_request_comments
        || request.source.as_deref() != Some("github_pull_request")
    {
        return true;
    }
    let association = request
        .author_association
        .as_deref()
        .unwrap_or_default()
        .to_uppercase();
    if matches!(
        association.as_str(),
        "OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR"
    ) {
        return true;
    }
    number_or_zero(request.comments_count) + number_or_zero(request.review_comments_count) > 0.0
}

fn is_comment_lane_in_trust_recovery(
    recent_outcomes: &[PublicRecentOutcome],
    policy: &RequiredPublicWorkPolicy,
) -> bool {
    recent_outcomes.iter().any(|entry| {
        policy
            .trust_recovery_statuses
            .contains(&normalize(entry.status.as_deref().unwrap_or_default()))
    })
}

fn has_update_verb(title: &str) -> bool {
    title
        .split(|value: char| !value.is_ascii_alphanumeric() && value != '_')
        .any(|word| matches!(word, "update" | "upgrade" | "bump"))
}

fn has_version_number(title: &str) -> bool {
    title
        .char_indices()
        .any(|(index, _)| is_word_boundary_start(title, index) && parses_version(&title[index..]))
}

fn normalize_labels(labels: &[String]) -> Vec<String> {
    labels
        .iter()
        .map(|label| normalize(label))
        .filter(|label| !label.is_empty())
        .collect()
}

fn normalize_values(values: Option<&[String]>, fallback: &[String]) -> Vec<String> {
    values.map_or_else(
        || fallback.to_vec(),
        |entries| {
            entries
                .iter()
                .map(|value| normalize(value))
                .filter(|value| !value.is_empty())
                .collect()
        },
    )
}

fn normalize(value: &str) -> String {
    value.trim().to_lowercase()
}

fn strings(values: &[&str]) -> Vec<String> {
    values.iter().map(|value| (*value).to_owned()).collect()
}

fn number_or_zero(value: Option<f64>) -> f64 {
    value.unwrap_or(0.0)
}

fn is_word_boundary_start(value: &str, index: usize) -> bool {
    let Some(current) = value[index..].chars().next() else {
        return false;
    };
    if !is_regex_word_char(current) {
        return false;
    }
    value[..index]
        .chars()
        .next_back()
        .is_none_or(|previous| !is_regex_word_char(previous))
}

fn is_regex_word_char(value: char) -> bool {
    value.is_ascii_alphanumeric() || value == '_'
}

fn parses_version(value: &str) -> bool {
    let bytes = value.as_bytes();
    let mut index = usize::from(bytes.first().is_some_and(|value| *value == b'v'));
    let left_start = index;
    while bytes.get(index).is_some_and(|value| value.is_ascii_digit()) {
        index += 1;
    }
    if index == left_start || bytes.get(index) != Some(&b'.') {
        return false;
    }
    index += 1;
    let right_start = index;
    while bytes.get(index).is_some_and(|value| value.is_ascii_digit()) {
        index += 1;
    }
    index > right_start
}