diff --git a/crates/codra-cli/README.md b/crates/codra-cli/README.md index a31f5dc..730fbed 100644 --- a/crates/codra-cli/README.md +++ b/crates/codra-cli/README.md @@ -20,6 +20,8 @@ codra run --task review-pr ## GitHub context +Real GitHub Actions mode is enabled only when `GITHUB_ACTIONS=true`. If `GITHUB_EVENT_PATH` is set outside Actions, the CLI parses it as a local fixture and keeps `mode` as `local`. + Reads GitHub Actions environment variables when present: - `GITHUB_REPOSITORY`, `GITHUB_EVENT_NAME`, `GITHUB_EVENT_PATH` @@ -41,7 +43,7 @@ With `--jsonl`, each line is a JSON object: | `codra.task.summary` | Deterministic task output | | `codra.task.completed` | Task finished | | `codra.run.completed` | Success | -| `codra.run.failed` | Unrecoverable error | +| `codra.run.failed` | Unrecoverable error (including invalid `--task` when `--jsonl` is set) | | `codra.warning` | Non-fatal issue | Fields: `type`, `runId`, `timestamp`, `task`, `source`, `data`. diff --git a/crates/codra-cli/src/context/github.rs b/crates/codra-cli/src/context/github.rs index 2cac6bd..b963f67 100644 --- a/crates/codra-cli/src/context/github.rs +++ b/crates/codra-cli/src/context/github.rs @@ -13,6 +13,11 @@ use super::types::{ }; use super::local_git::load_local_git; +/// True only when running in a real GitHub Actions job (`GITHUB_ACTIONS=true`). +pub fn github_actions_runtime_enabled() -> bool { + env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") +} + const API_TIMEOUT_SECS: u64 = 15; const MAX_CHANGED_FILES: usize = 100; const MAX_PATCH_LEN: usize = 32_000; @@ -45,8 +50,7 @@ pub fn load_github_context() -> CodraGitHubContext { } } - let in_actions = env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") - || ctx.event_path.is_some(); + let in_actions = github_actions_runtime_enabled(); if in_actions { ctx.mode = GitHubContextMode::GitHubActions; @@ -55,6 +59,16 @@ pub fn load_github_context() -> CodraGitHubContext { } else { ctx.warnings .push("not running inside GitHub Actions; GitHub event context unavailable".to_string()); + if ctx.event_path.is_some() { + ctx.warnings.push( + "GITHUB_EVENT_PATH detected outside GitHub Actions; treating as local fixture context." + .to_string(), + ); + parse_event_payload(&mut ctx); + if ctx.pull_request.is_some() || ctx.issue.is_some() { + ctx.available = true; + } + } } let token = env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()); diff --git a/crates/codra-cli/src/context/mod.rs b/crates/codra-cli/src/context/mod.rs index b2d050d..758443e 100644 --- a/crates/codra-cli/src/context/mod.rs +++ b/crates/codra-cli/src/context/mod.rs @@ -2,5 +2,5 @@ pub mod github; pub mod local_git; pub mod types; -pub use github::load_github_context; +pub use github::{github_actions_runtime_enabled, load_github_context}; pub use types::CodraGitHubContext; \ No newline at end of file diff --git a/crates/codra-cli/src/events.rs b/crates/codra-cli/src/events.rs index c099d05..c8b0f76 100644 --- a/crates/codra-cli/src/events.rs +++ b/crates/codra-cli/src/events.rs @@ -102,7 +102,8 @@ impl EventEmitter { "codra.run.failed" => { let msg = event .data - .get("message") + .get("error") + .or_else(|| event.data.get("message")) .and_then(|v| v.as_str()) .unwrap_or("unknown error"); eprintln!("✗ Run failed: {msg}"); diff --git a/crates/codra-cli/src/lib.rs b/crates/codra-cli/src/lib.rs index 242acd6..b7f77aa 100644 --- a/crates/codra-cli/src/lib.rs +++ b/crates/codra-cli/src/lib.rs @@ -4,4 +4,7 @@ pub mod run; pub mod tasks; pub mod utils; -pub use run::{parse_run_args, run_task, RunOptions, VALID_TASKS}; \ No newline at end of file +pub use run::{ + args_want_jsonl, emit_argument_validation_failed, execute_run, parse_run_args, peek_task_label, + run_task, RunOptions, VALID_TASKS, +}; \ No newline at end of file diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index bacff25..283a1fd 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -1,4 +1,4 @@ -use codra_cli::run::{parse_run_args, run_task}; +use codra_cli::run::{args_want_jsonl, execute_run}; use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider}; use codra_core::provider_config::ProviderConfigService; use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind}; @@ -36,7 +36,15 @@ fn main() { "mcp-server" => mcp_server(), "run" => { args.remove(0); - run_command(&args) + match execute_run(&args) { + Ok(()) => Ok(()), + Err(err) => { + if args_want_jsonl(&args) { + std::process::exit(1); + } + Err(err) + } + } } _ => help(), }; @@ -521,11 +529,6 @@ fn parse_worker_url(url: &str) -> Result<(String, u16), String> { Ok((host, port)) } -fn run_command(args: &[String]) -> Result<(), String> { - let opts = parse_run_args(args)?; - run_task(opts) -} - fn help() -> Result<(), String> { println!("codra "); println!(" run --task [--jsonl] Run a task with optional JSONL event stream"); diff --git a/crates/codra-cli/src/run.rs b/crates/codra-cli/src/run.rs index 3c559a2..24ddf5d 100644 --- a/crates/codra-cli/src/run.rs +++ b/crates/codra-cli/src/run.rs @@ -15,6 +15,56 @@ pub struct RunOptions { pub jsonl: bool, } +pub fn args_want_jsonl(args: &[String]) -> bool { + args.iter().any(|a| a == "--jsonl") +} + +/// Task name from `--task` if present; otherwise `"unknown"`. +pub fn peek_task_label(args: &[String]) -> String { + let mut i = 0; + while i < args.len() { + if args[i] == "--task" { + return args + .get(i + 1) + .cloned() + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + } + i += 1; + } + "unknown".to_string() +} + +pub fn emit_argument_validation_failed(task: &str, error: &str) -> Result<(), String> { + let run_id = new_run_id(); + let safe = crate::utils::safe_error::redact_secrets(error); + let emitter = EventEmitter::new(run_id, task.to_string(), true); + emitter.emit( + "codra.run.failed", + json!({ + "error": safe, + "stage": "argument_validation", + "secretsExposed": false + }), + ) +} + +/// Parse and run a task, emitting `codra.run.failed` for JSONL argument validation errors. +pub fn execute_run(args: &[String]) -> Result<(), String> { + let jsonl = args_want_jsonl(args); + let task_label = peek_task_label(args); + + match parse_run_args(args) { + Ok(opts) => run_task(opts), + Err(err) => { + if jsonl { + emit_argument_validation_failed(&task_label, &err)?; + } + Err(err) + } + } +} + pub fn parse_run_args(args: &[String]) -> Result { let mut task: Option = None; let mut jsonl = false; diff --git a/crates/codra-cli/tests/github_actions_detection_test.rs b/crates/codra-cli/tests/github_actions_detection_test.rs new file mode 100644 index 0000000..024b608 --- /dev/null +++ b/crates/codra-cli/tests/github_actions_detection_test.rs @@ -0,0 +1,100 @@ +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +use codra_cli::context::github::github_actions_runtime_enabled; +use codra_cli::context::load_github_context; +use codra_cli::context::types::GitHubContextMode; + +static ENV_LOCK: OnceLock> = OnceLock::new(); + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pull_request_event.json") +} + +fn clear_github_env() { + for key in [ + "GITHUB_ACTIONS", + "GITHUB_EVENT_PATH", + "GITHUB_EVENT_NAME", + "GITHUB_REPOSITORY", + "GITHUB_TOKEN", + ] { + std::env::remove_var(key); + } +} + +#[test] +fn github_actions_detection_helper() { + let _lock = env_lock(); + clear_github_env(); + assert!(!github_actions_runtime_enabled()); + + std::env::set_var("GITHUB_ACTIONS", "true"); + assert!(github_actions_runtime_enabled()); + + std::env::set_var("GITHUB_ACTIONS", "false"); + assert!(!github_actions_runtime_enabled()); + + std::env::set_var("GITHUB_EVENT_PATH", "/tmp/event.json"); + assert!(!github_actions_runtime_enabled()); + clear_github_env(); +} + +#[test] +fn event_path_alone_does_not_set_github_actions_mode() { + let _lock = env_lock(); + clear_github_env(); + std::env::set_var( + "GITHUB_EVENT_PATH", + fixture_path().to_str().expect("fixture path utf-8"), + ); + std::env::set_var("GITHUB_EVENT_NAME", "pull_request"); + + let ctx = load_github_context(); + assert_eq!(ctx.mode, GitHubContextMode::Local); + assert!(ctx.pull_request.is_some()); + assert!(ctx.warnings.iter().any(|w| { + w.contains("GITHUB_EVENT_PATH detected outside GitHub Actions") + })); + + clear_github_env(); +} + +#[test] +fn github_actions_true_with_event_path_sets_actions_mode() { + let _lock = env_lock(); + clear_github_env(); + std::env::set_var("GITHUB_ACTIONS", "true"); + std::env::set_var( + "GITHUB_EVENT_PATH", + fixture_path().to_str().expect("fixture path utf-8"), + ); + std::env::set_var("GITHUB_EVENT_NAME", "pull_request"); + + let ctx = load_github_context(); + assert_eq!(ctx.mode, GitHubContextMode::GitHubActions); + assert!(ctx.pull_request.is_some()); + assert!(!ctx.warnings.iter().any(|w| { + w.contains("GITHUB_EVENT_PATH detected outside GitHub Actions") + })); + + clear_github_env(); +} + +#[test] +fn outputs_never_contain_github_token_value() { + let _lock = env_lock(); + clear_github_env(); + std::env::set_var("GITHUB_TOKEN", "ghp_super_secret_token_value"); + let ctx = load_github_context(); + let blob = format!("{ctx:?}"); + assert!(!blob.contains("ghp_super_secret_token_value")); + clear_github_env(); +} \ No newline at end of file diff --git a/crates/codra-cli/tests/run_args_test.rs b/crates/codra-cli/tests/run_args_test.rs index 4cd0d70..c4b6dda 100644 --- a/crates/codra-cli/tests/run_args_test.rs +++ b/crates/codra-cli/tests/run_args_test.rs @@ -1,5 +1,4 @@ -use codra_cli::parse_run_args; -use codra_cli::VALID_TASKS; +use codra_cli::{parse_run_args, peek_task_label, VALID_TASKS}; #[test] fn parses_task_and_jsonl_flag() { @@ -21,4 +20,15 @@ fn rejects_invalid_task() { for t in VALID_TASKS { assert!(err.contains(t)); } +} + +#[test] +fn peek_task_label_unknown_when_missing() { + assert_eq!(peek_task_label(&[]), "unknown"); +} + +#[test] +fn peek_task_label_reads_invalid_task_name() { + let args = vec!["--task".to_string(), "not-a-task".to_string(), "--jsonl".to_string()]; + assert_eq!(peek_task_label(&args), "not-a-task"); } \ No newline at end of file diff --git a/crates/codra-cli/tests/run_failure_test.rs b/crates/codra-cli/tests/run_failure_test.rs new file mode 100644 index 0000000..23a9223 --- /dev/null +++ b/crates/codra-cli/tests/run_failure_test.rs @@ -0,0 +1,65 @@ +use std::process::Command; + +use codra_cli::execute_run; + +fn codra_bin() -> String { + std::env::var("CARGO_BIN_EXE_codra").expect("CARGO_BIN_EXE_codra must be set for integration tests") +} + +#[test] +fn invalid_task_jsonl_emits_run_failed() { + let args = vec![ + "--task".to_string(), + "not-a-real-task".to_string(), + "--jsonl".to_string(), + ]; + let err = execute_run(&args).unwrap_err(); + assert!(err.contains("invalid task")); + + let output = Command::new(codra_bin()) + .args(["run", "--task", "not-a-real-task", "--jsonl"]) + .output() + .expect("run codra binary"); + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"type\":\"codra.run.failed\"") || stdout.contains("codra.run.failed")); + assert!(stdout.contains("argument_validation")); + assert!(stdout.contains("not-a-real-task")); + assert!(stdout.contains("secretsExposed")); + let combined = format!( + "{}{}", + stdout, + String::from_utf8_lossy(&output.stderr) + ); + assert!(!combined.contains("ghp_")); + assert!(!combined.to_lowercase().contains("authorization: bearer")); +} + +#[test] +fn invalid_task_without_jsonl_is_human_readable() { + let output = Command::new(codra_bin()) + .args(["run", "--task", "not-a-real-task"]) + .output() + .expect("run codra binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("codra:")); + assert!(stderr.contains("invalid task")); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("codra.run.failed")); +} + +#[test] +fn invalid_task_jsonl_never_prints_token() { + let output = Command::new(codra_bin()) + .env("GITHUB_TOKEN", "ghp_test_secret_value") + .args(["run", "--task", "bad", "--jsonl"]) + .output() + .expect("run codra binary"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!combined.contains("ghp_test_secret_value")); +} \ No newline at end of file