diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 690b8821..3642ab23 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,7 @@ updates: actions: patterns: - "*" + open-pull-requests-limit: 2 # Rust dependencies. Keep every Cargo project in one update entry so the # open-PR limit applies to the fleet as a whole, rather than once per bot. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c9703681..94324102 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -44,15 +44,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.8 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.8 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index cc2965a5..671ad26c 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -34,4 +34,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd # main 2026-06-27 + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@8f31a5a4ba591d544b65f91f6d78b136e07756f0 # main 2026-06-27 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 6798fb6d..111a63cc 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -30,7 +30,7 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@cc58c0cb23f73fc2019ce85a56a468e5248a93b3 secrets: inherit # Total caller-side wall-clock cap for the reusable. Matches # Hypatia's `missing_timeout_minutes` rule expectation. The scan is diff --git a/.github/workflows/inbox-steward.yml b/.github/workflows/inbox-steward.yml index 7169dff8..ff3f3947 100644 --- a/.github/workflows/inbox-steward.yml +++ b/.github/workflows/inbox-steward.yml @@ -47,7 +47,7 @@ on: # MONITORING; the auto-merge job itself is gated OFF by default (see below). permissions: - contents: write + contents: read pull-requests: write repository-projects: read actions: read diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 833b2c87..e86c9dc1 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,5 +15,5 @@ jobs: permissions: security-events: write id-token: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8750b94ac1bbe8c51ad13fe106669b13478f0b62 secrets: inherit diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index b8f8f501..dede6a8c 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -175,7 +175,11 @@ async fn report_handler( match format.to_lowercase().as_str() { "html" => (StatusCode::OK, [("content-type", "text/html")], report), - "json" => (StatusCode::OK, [("content-type", "application/json")], report), + "json" => ( + StatusCode::OK, + [("content-type", "application/json")], + report, + ), _ => (StatusCode::OK, [("content-type", "text/plain")], report), } } @@ -211,10 +215,7 @@ async fn websocket_handler( } /// Handle WebSocket connection -async fn websocket_connection( - mut socket: axum::extract::ws::WebSocket, - state: AppState, -) { +async fn websocket_connection(mut socket: axum::extract::ws::WebSocket, state: AppState) { use axum::extract::ws::Message; use tokio::time::{interval, Duration}; diff --git a/robot-repo-automaton/.github/dependabot.yml b/robot-repo-automaton/.github/dependabot.yml index 2f80405e..e41f2659 100644 --- a/robot-repo-automaton/.github/dependabot.yml +++ b/robot-repo-automaton/.github/dependabot.yml @@ -8,6 +8,7 @@ updates: actions: patterns: - "*" + open-pull-requests-limit: 2 - package-ecosystem: "cargo" directory: "/" @@ -16,13 +17,16 @@ updates: ignore: - dependency-name: "*" update-types: ["version-update:semver-patch"] + open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 3 - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 3 diff --git a/robot-repo-automaton/src/fixer.rs b/robot-repo-automaton/src/fixer.rs index c288a44d..91a788ad 100644 --- a/robot-repo-automaton/src/fixer.rs +++ b/robot-repo-automaton/src/fixer.rs @@ -1,12 +1,394 @@ - content - .replace("gitbot-fleet", repo_name) - .replace("{{LICENSE}}", "MPL-2.0") - .replace("{{YEAR}}", &year) - .replace("{{AUTHOR}}", "Jonathan D.A. Jewell") - .replace("{{EMAIL}}", "j.d.a.jewell@open.ac.uk"); - content - .replace("gitbot-fleet", repo_name) - .replace("{{LICENSE}}", "MPL-2.0") - .replace("{{YEAR}}", &year) - .replace("{{AUTHOR}}", "Jonathan D.A. Jewell") - .replace("{{EMAIL}}", "j.d.a.jewell@open.ac.uk") +// SPDX-License-Identifier: MPL-2.0 +//! Fix application - delete, modify, create, disable. + +use regex::Regex; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use crate::catalog::{Fix, FixAction}; +use crate::detector::DetectedIssue; +use crate::error::{Error, Result}; + +/// Result of applying a single fix. +#[derive(Debug, Clone)] +pub struct FixResult { + pub success: bool, + pub files_modified: Vec, + pub action_taken: String, + pub error: Option, +} + +/// Applies fixes to a repository on disk. +pub struct Fixer { + repo_path: PathBuf, + dry_run: bool, +} + +impl Fixer { + /// Create a new fixer for the given repository. + pub fn new(repo_path: PathBuf, dry_run: bool) -> Self { + Self { repo_path, dry_run } + } + + /// Resolve a fix target relative to the repository root, rejecting any + /// path that would escape the repository. The target need not exist + /// (e.g. for `Create`), so this is pure path arithmetic. + fn resolve_target(&self, target: &str) -> Result { + let joined = self.repo_path.join(target); + + let mut normalized = PathBuf::new(); + for component in joined.components() { + match component { + Component::ParentDir => { + if !normalized.pop() { + return Err(Error::Fix(format!( + "Fix target '{target}' resolves outside the repository" + ))); + } + } + Component::CurDir => {} + other => normalized.push(other), + } + } + + if !normalized.starts_with(&self.repo_path) { + return Err(Error::Fix(format!( + "Fix target '{target}' resolves outside the repository" + ))); + } + + Ok(normalized) + } + + /// Apply a single fix, returning the outcome. A rejected or failed fix + /// is reported via `FixResult`, not `Err` (the operation itself did not + /// error; the requested change simply could not be made safely). + pub fn apply(&self, _issue: &DetectedIssue, fix: &Fix) -> Result { + let target = match self.resolve_target(&fix.target) { + Ok(path) => path, + Err(e) => { + return Ok(FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "rejected".to_string(), + error: Some(e.to_string()), + }); + } + }; + + let result = match fix.action { + FixAction::Delete => self.apply_delete(&target), + FixAction::Modify => self.apply_modify(&target, fix), + FixAction::Create => self.apply_create(&target, fix), + FixAction::Disable => FixResult { + success: true, + files_modified: Vec::new(), + action_taken: "Disable: no-op, manual review required".to_string(), + error: None, + }, + }; + + Ok(result) + } + + fn apply_delete(&self, target: &Path) -> FixResult { + if !target.exists() { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("Delete: {} already absent", target.display()), + error: None, + }; + } + + if self.dry_run { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("DRY RUN: would delete {}", target.display()), + error: None, + }; + } + + match fs::remove_file(target) { + Ok(()) => FixResult { + success: true, + files_modified: vec![target.to_path_buf()], + action_taken: format!("Deleted {}", target.display()), + error: None, + }, + Err(e) => FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Delete: failed".to_string(), + error: Some(e.to_string()), + }, + } + } + + fn apply_create(&self, target: &Path, fix: &Fix) -> FixResult { + if target.exists() { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("Create: {} already exists", target.display()), + error: None, + }; + } + + if self.dry_run { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("DRY RUN: would create {}", target.display()), + error: None, + }; + } + + if let Some(parent) = target.parent() { + if let Err(e) = fs::create_dir_all(parent) { + return FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Create: failed".to_string(), + error: Some(e.to_string()), + }; + } + } + + let content = fix.fallback.clone().unwrap_or_default(); + match fs::write(target, content) { + Ok(()) => FixResult { + success: true, + files_modified: vec![target.to_path_buf()], + action_taken: format!("Created {}", target.display()), + error: None, + }, + Err(e) => FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Create: failed".to_string(), + error: Some(e.to_string()), + }, + } + } + + fn apply_modify(&self, target: &Path, fix: &Fix) -> FixResult { + let Some(modification) = fix.modification.as_deref() else { + return FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Modify: failed".to_string(), + error: Some("Modify fix has no modification instruction".to_string()), + }; + }; + + let original = match fs::read(target) { + Ok(bytes) => bytes, + Err(e) => { + return FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Modify: failed".to_string(), + error: Some(e.to_string()), + }; + } + }; + + let original_text = match String::from_utf8(original) { + Ok(text) => text, + Err(_) => { + return FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Modify: failed".to_string(), + error: Some(format!( + "Refusing to modify binary file {}", + target.display() + )), + }; + } + }; + + let new_text = match Self::apply_modification(&original_text, modification) { + Ok(text) => text, + Err(e) => { + return FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Modify: failed".to_string(), + error: Some(e), + }; + } + }; + + if self.dry_run { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("DRY RUN: would modify {}", target.display()), + error: None, + }; + } + + if new_text == original_text { + return FixResult { + success: true, + files_modified: Vec::new(), + action_taken: format!("Modify: {} already up to date", target.display()), + error: None, + }; + } + + match fs::write(target, new_text) { + Ok(()) => FixResult { + success: true, + files_modified: vec![target.to_path_buf()], + action_taken: format!("Modified {}", target.display()), + error: None, + }, + Err(e) => FixResult { + success: false, + files_modified: Vec::new(), + action_taken: "Modify: failed".to_string(), + error: Some(e.to_string()), + }, + } + } + + /// Apply a `replace-line:`, `replace-pattern:`, `insert-before:` or + /// `insert-after:` modification instruction to `content`. + fn apply_modification( + content: &str, + modification: &str, + ) -> std::result::Result { + if let Some(rest) = modification.strip_prefix("replace-pattern:") { + let (pattern, replacement) = rest + .split_once(':') + .ok_or_else(|| "Invalid replace-pattern instruction".to_string())?; + let re = Regex::new(pattern).map_err(|e| format!("Invalid regex: {e}"))?; + return Ok(re.replace_all(content, replacement).into_owned()); + } + + let mut lines: Vec = content.lines().map(str::to_string).collect(); + let trailing_newline = content.ends_with('\n'); + + if let Some(rest) = modification.strip_prefix("replace-line:") { + let (line_no, replacement) = rest + .split_once(':') + .ok_or_else(|| "Invalid replace-line instruction".to_string())?; + let line_no: usize = line_no + .parse() + .map_err(|_| "Invalid line number in replace-line instruction".to_string())?; + if line_no == 0 || line_no > lines.len() { + return Err(format!( + "replace-line: line {line_no} does not exist (file has {} lines)", + lines.len() + )); + } + lines[line_no - 1] = replacement.to_string(); + } else if let Some(rest) = modification.strip_prefix("insert-before:") { + let (line_no, text) = rest + .split_once(':') + .ok_or_else(|| "Invalid insert-before instruction".to_string())?; + let line_no: usize = line_no + .parse() + .map_err(|_| "Invalid line number in insert-before instruction".to_string())?; + if line_no == 0 || line_no > lines.len() + 1 { + return Err(format!( + "insert-before: line {line_no} does not exist (file has {} lines)", + lines.len() + )); + } + lines.insert(line_no - 1, text.to_string()); + } else if let Some(rest) = modification.strip_prefix("insert-after:") { + let (line_no, text) = rest + .split_once(':') + .ok_or_else(|| "Invalid insert-after instruction".to_string())?; + let line_no: usize = line_no + .parse() + .map_err(|_| "Invalid line number in insert-after instruction".to_string())?; + if line_no == 0 || line_no > lines.len() { + return Err(format!( + "insert-after: line {line_no} does not exist (file has {} lines)", + lines.len() + )); + } + lines.insert(line_no, text.to_string()); + } else { + return Err(format!("Unknown modification instruction: {modification}")); + } + + let mut result = lines.join("\n"); + if trailing_newline { + result.push('\n'); + } + Ok(result) + } + + /// Apply a batch of auto-approved fixes and commit the results locally. + pub fn apply_and_commit( + &self, + _issues: &[DetectedIssue], + auto_fixes: &[(DetectedIssue, Fix)], + ) -> Result> { + let mut results = Vec::with_capacity(auto_fixes.len()); + let mut modified: Vec = Vec::new(); + let mut messages: Vec = Vec::new(); + + for (issue, fix) in auto_fixes { + let result = self.apply(issue, fix)?; + if result.success && !result.files_modified.is_empty() { + modified.extend(result.files_modified.clone()); + messages.push(issue.commit_message.clone()); + } + results.push(result); + } + + if !self.dry_run && !modified.is_empty() { + self.commit_changes(&modified, &messages)?; + } + + Ok(results) + } + + /// Stage and commit the given files in the local repository. + fn commit_changes(&self, files: &[PathBuf], messages: &[String]) -> Result<()> { + let repo = git2::Repository::open(&self.repo_path)?; + let mut index = repo.index()?; + + for file in files { + let relative = file.strip_prefix(&self.repo_path).unwrap_or(file); + if file.exists() { + index.add_path(relative)?; + } else { + let _ = index.remove_path(relative); + } + } + index.write()?; + + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let signature = git2::Signature::now("robot-repo-automaton", "noreply@hyperpolymath.dev")?; + + let message = if messages.is_empty() { + "fix: automated compliance fixes".to_string() + } else { + messages.join("\n") + }; + + let parent_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); + let parents: Vec<&git2::Commit> = parent_commit.iter().collect(); + + repo.commit( + Some("HEAD"), + &signature, + &signature, + &message, + &tree, + &parents, + )?; + + Ok(()) + } +} diff --git a/robot-repo-automaton/src/hypatia.rs b/robot-repo-automaton/src/hypatia.rs index e0ab4ba8..adf51c69 100644 --- a/robot-repo-automaton/src/hypatia.rs +++ b/robot-repo-automaton/src/hypatia.rs @@ -619,13 +619,15 @@ fn recipe_to_rule(recipe: &serde_json::Value) -> Option { // Build pattern from recipe detection info let pattern = if let Some(glob) = recipe.get("file_glob").and_then(|v| v.as_str()) { RulePattern::FileGlob { glob: glob.to_string() } - } else if let Some(regex) = recipe.get("pattern").and_then(|v| v.as_str()) { + } else { + let regex = recipe.get("pattern").and_then(|v| v.as_str())?; RulePattern::ContentRegex { regex: regex.to_string(), - file_glob: recipe.get("applies_to").and_then(|v| v.as_str()).map(|s| s.to_string()), + file_glob: recipe + .get("applies_to") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), } - } else { - return None; }; // Build fix from recipe