From 28d4d71c145287722e1a98253d6bf00ce6377212 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 10:13:40 +0000 Subject: [PATCH] feat(cli): add event protocol and GitHub context adapter --- Cargo.lock | 1 + README.md | 14 + crates/codra-cli/Cargo.toml | 5 + crates/codra-cli/README.md | 53 +++ crates/codra-cli/src/context/github.rs | 407 ++++++++++++++++++ crates/codra-cli/src/context/local_git.rs | 61 +++ crates/codra-cli/src/context/mod.rs | 6 + crates/codra-cli/src/context/types.rs | 115 +++++ crates/codra-cli/src/events.rs | 142 ++++++ crates/codra-cli/src/lib.rs | 7 + crates/codra-cli/src/main.rs | 12 + crates/codra-cli/src/run.rs | 107 +++++ crates/codra-cli/src/tasks/explain_issue.rs | 48 +++ crates/codra-cli/src/tasks/mod.rs | 21 + crates/codra-cli/src/tasks/review_pr.rs | 107 +++++ .../codra-cli/src/tasks/summarize_context.rs | 39 ++ crates/codra-cli/src/utils/ids.rs | 17 + crates/codra-cli/src/utils/mod.rs | 3 + crates/codra-cli/src/utils/safe_error.rs | 14 + crates/codra-cli/src/utils/time.rs | 5 + .../tests/fixtures/pull_request_event.json | 15 + crates/codra-cli/tests/github_context_test.rs | 40 ++ crates/codra-cli/tests/run_args_test.rs | 24 ++ 23 files changed, 1263 insertions(+) create mode 100644 crates/codra-cli/README.md create mode 100644 crates/codra-cli/src/context/github.rs create mode 100644 crates/codra-cli/src/context/local_git.rs create mode 100644 crates/codra-cli/src/context/mod.rs create mode 100644 crates/codra-cli/src/context/types.rs create mode 100644 crates/codra-cli/src/events.rs create mode 100644 crates/codra-cli/src/lib.rs create mode 100644 crates/codra-cli/src/run.rs create mode 100644 crates/codra-cli/src/tasks/explain_issue.rs create mode 100644 crates/codra-cli/src/tasks/mod.rs create mode 100644 crates/codra-cli/src/tasks/review_pr.rs create mode 100644 crates/codra-cli/src/tasks/summarize_context.rs create mode 100644 crates/codra-cli/src/utils/ids.rs create mode 100644 crates/codra-cli/src/utils/mod.rs create mode 100644 crates/codra-cli/src/utils/safe_error.rs create mode 100644 crates/codra-cli/src/utils/time.rs create mode 100644 crates/codra-cli/tests/fixtures/pull_request_event.json create mode 100644 crates/codra-cli/tests/github_context_test.rs create mode 100644 crates/codra-cli/tests/run_args_test.rs diff --git a/Cargo.lock b/Cargo.lock index 822a3a2..b867da9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,7 @@ dependencies = [ "codra-runtime", "codra-tools", "reqwest 0.12.28", + "serde", "serde_json", "tokio", ] diff --git a/README.md b/README.md index 9b61ace..a1ec90c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ cargo check cargo test ``` +## Codra CLI (event protocol + GitHub context) + +```bash +cargo build -p codra-cli + +# JSONL agent run events (no AI provider keys required) +codra run --task review-pr --jsonl +codra run --task summarize-context + +# Optional: GITHUB_TOKEN enriches PR/issue data via GitHub API +``` + +See [crates/codra-cli/README.md](crates/codra-cli/README.md). + ## Roadmap See [docs/ROADMAP.md](docs/ROADMAP.md) for the full phased roadmap. diff --git a/crates/codra-cli/Cargo.toml b/crates/codra-cli/Cargo.toml index ae1912b..ee81e80 100644 --- a/crates/codra-cli/Cargo.toml +++ b/crates/codra-cli/Cargo.toml @@ -3,11 +3,16 @@ name = "codra-cli" version = "0.1.0" edition = "2021" +[lib] +name = "codra_cli" +path = "src/lib.rs" + [[bin]] name = "codra" path = "src/main.rs" [dependencies] +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true codra-core = { path = "../codra-core" } codra-protocol = { path = "../codra-protocol" } diff --git a/crates/codra-cli/README.md b/crates/codra-cli/README.md new file mode 100644 index 0000000..a31f5dc --- /dev/null +++ b/crates/codra-cli/README.md @@ -0,0 +1,53 @@ +# codra-cli + +Local-first Codra CLI with JSONL event protocol and GitHub context adapter. + +## Usage + +```bash +# Build +cargo build -p codra-cli + +# JSONL event stream (no AI keys required) +codra run --task review-pr --jsonl +codra run --task explain-issue --jsonl +codra run --task summarize-context --jsonl + +# Human-readable output +codra run --task summarize-context +codra run --task review-pr +``` + +## GitHub context + +Reads GitHub Actions environment variables when present: + +- `GITHUB_REPOSITORY`, `GITHUB_EVENT_NAME`, `GITHUB_EVENT_PATH` +- `GITHUB_SHA`, `GITHUB_REF`, `GITHUB_BASE_REF`, `GITHUB_HEAD_REF` +- `GITHUB_TOKEN` (optional — enables API enrichment) + +Without `GITHUB_TOKEN`, the CLI still runs and emits warnings instead of failing. + +## Event protocol + +With `--jsonl`, each line is a JSON object: + +| Type | When | +|------|------| +| `codra.run.started` | Run begins | +| `codra.context.loading` | Context load starts | +| `codra.context.loaded` | Context ready | +| `codra.task.started` | Task execution starts | +| `codra.task.summary` | Deterministic task output | +| `codra.task.completed` | Task finished | +| `codra.run.completed` | Success | +| `codra.run.failed` | Unrecoverable error | +| `codra.warning` | Non-fatal issue | + +Fields: `type`, `runId`, `timestamp`, `task`, `source`, `data`. + +## Extension points (Phase 3+) + +- GitHub Action: `codra run --task review-pr --jsonl` +- PR comment upload from final `codra.task.summary` +- Desktop session viewer can tail JSONL events \ No newline at end of file diff --git a/crates/codra-cli/src/context/github.rs b/crates/codra-cli/src/context/github.rs new file mode 100644 index 0000000..2cac6bd --- /dev/null +++ b/crates/codra-cli/src/context/github.rs @@ -0,0 +1,407 @@ +use std::env; +use std::fs; +use std::time::Duration; + +use reqwest::blocking::Client; +use serde_json::Value; + +use crate::utils::safe_error::redact_secrets; + +use super::types::{ + CodraChangedFile, CodraCheckSummary, CodraGitHubContext, CodraIssueComment, CodraIssueContext, + CodraPullRequestContext, GitHubContextMode, +}; +use super::local_git::load_local_git; + +const API_TIMEOUT_SECS: u64 = 15; +const MAX_CHANGED_FILES: usize = 100; +const MAX_PATCH_LEN: usize = 32_000; + +pub fn load_github_context() -> CodraGitHubContext { + let mut ctx = CodraGitHubContext { + available: false, + mode: GitHubContextMode::Local, + repository: env::var("GITHUB_REPOSITORY").ok(), + owner: None, + repo: None, + event_name: env::var("GITHUB_EVENT_NAME").ok(), + event_path: env::var("GITHUB_EVENT_PATH").ok(), + sha: env::var("GITHUB_SHA").ok(), + ref_name: env::var("GITHUB_REF").ok(), + base_ref: env::var("GITHUB_BASE_REF").ok(), + head_ref: env::var("GITHUB_HEAD_REF").ok(), + pull_request: None, + issue: None, + checks: None, + local_git: None, + warnings: Vec::new(), + }; + + if let Some(ref full_repo) = ctx.repository { + let parts: Vec<&str> = full_repo.splitn(2, '/').collect(); + if parts.len() == 2 { + ctx.owner = Some(parts[0].to_string()); + ctx.repo = Some(parts[1].to_string()); + } + } + + let in_actions = env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") + || ctx.event_path.is_some(); + + if in_actions { + ctx.mode = GitHubContextMode::GitHubActions; + parse_event_payload(&mut ctx); + ctx.available = ctx.pull_request.is_some() || ctx.issue.is_some(); + } else { + ctx.warnings + .push("not running inside GitHub Actions; GitHub event context unavailable".to_string()); + } + + let token = env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()); + if token.is_none() { + ctx.warnings + .push("GITHUB_TOKEN not set; skipping GitHub API enrichment".to_string()); + } else if let (Some(owner), Some(repo)) = (ctx.owner.clone(), ctx.repo.clone()) { + enrich_from_api(&mut ctx, &owner, &repo, token.as_deref().unwrap()); + } + + let (local_git, git_warnings) = load_local_git(); + ctx.local_git = Some(local_git); + ctx.warnings.extend(git_warnings); + + if !in_actions && ctx.pull_request.is_none() && ctx.issue.is_none() { + if ctx.local_git.as_ref().and_then(|g| g.root.as_ref()).is_some() { + ctx.available = true; + } + } + + ctx +} + +fn parse_event_payload(ctx: &mut CodraGitHubContext) { + let path = match &ctx.event_path { + Some(p) => p.clone(), + None => { + ctx.warnings + .push("GITHUB_EVENT_PATH not set; cannot read event payload".to_string()); + return; + } + }; + + let raw = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + ctx.warnings.push(redact_secrets(&format!( + "failed to read GITHUB_EVENT_PATH: {e}" + ))); + return; + } + }; + + let event_name = ctx.event_name.clone().unwrap_or_default(); + parse_event_payload_from_str(ctx, &event_name, &raw); +} + +pub fn parse_event_payload_from_str( + ctx: &mut CodraGitHubContext, + event_name: &str, + raw: &str, +) { + let payload: Value = match serde_json::from_str(raw) { + Ok(v) => v, + Err(e) => { + ctx.warnings + .push(format!("failed to parse GitHub event payload: {e}")); + return; + } + }; + + match event_name { + "pull_request" => parse_pull_request_event(ctx, &payload), + "issues" => { + if let Some(issue) = payload.get("issue") { + merge_issue(ctx, issue); + } else { + ctx.warnings + .push("issues event missing issue object".to_string()); + } + } + "issue_comment" => { + if let Some(issue) = payload.get("issue") { + merge_issue(ctx, issue); + } else { + ctx.warnings + .push("issue_comment event missing issue object".to_string()); + } + } + other if !other.is_empty() => { + ctx.warnings + .push(format!("unsupported GitHub event for context: {other}")); + } + _ => {} + } +} + +fn parse_pull_request_event(ctx: &mut CodraGitHubContext, payload: &Value) { + let pr = match payload.get("pull_request") { + Some(pr) => pr, + None => { + ctx.warnings + .push("pull_request event missing pull_request object".to_string()); + return; + } + }; + + let number = pr.get("number").and_then(|v| v.as_u64()).unwrap_or(0); + if number == 0 { + ctx.warnings.push("pull_request number missing in event".to_string()); + return; + } + + ctx.pull_request = Some(CodraPullRequestContext { + number, + title: pr.get("title").and_then(|v| v.as_str()).map(str::to_string), + body: pr.get("body").and_then(|v| v.as_str()).map(str::to_string), + base_ref: pr + .get("base") + .and_then(|b| b.get("ref")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| ctx.base_ref.clone()), + head_ref: pr + .get("head") + .and_then(|h| h.get("ref")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| ctx.head_ref.clone()), + author: pr + .get("user") + .and_then(|u| u.get("login")) + .and_then(|v| v.as_str()) + .map(str::to_string), + changed_files: None, + diff_text: None, + }); + ctx.available = true; +} + +fn merge_issue(ctx: &mut CodraGitHubContext, issue: &Value) { + let number = issue.get("number").and_then(|v| v.as_u64()).unwrap_or(0); + if number == 0 { + return; + } + ctx.issue = Some(CodraIssueContext { + number, + title: issue.get("title").and_then(|v| v.as_str()).map(str::to_string), + body: issue.get("body").and_then(|v| v.as_str()).map(str::to_string), + author: issue + .get("user") + .and_then(|u| u.get("login")) + .and_then(|v| v.as_str()) + .map(str::to_string), + comments: None, + }); + ctx.available = true; +} + +fn enrich_from_api(ctx: &mut CodraGitHubContext, owner: &str, repo: &str, token: &str) { + let client = match Client::builder() + .timeout(Duration::from_secs(API_TIMEOUT_SECS)) + .build() + { + Ok(c) => c, + Err(e) => { + ctx.warnings + .push(redact_secrets(&format!("GitHub API client error: {e}"))); + return; + } + }; + + if let Some(number) = ctx.pull_request.as_ref().map(|p| p.number) { + enrich_pull_request(&client, ctx, owner, repo, token, number); + } + + if let Some(number) = ctx.issue.as_ref().map(|i| i.number) { + enrich_issue(&client, ctx, owner, repo, token, number); + } +} + +fn enrich_pull_request( + client: &Client, + ctx: &mut CodraGitHubContext, + owner: &str, + repo: &str, + token: &str, + number: u64, +) { + let base = format!("https://api.github.com/repos/{owner}/{repo}"); + let pr_url = format!("{base}/pulls/{number}"); + + if let Ok(resp) = github_get(client, &pr_url, token) { + if let Some(pr) = &mut ctx.pull_request { + pr.title = resp + .get("title") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or(pr.title.clone()); + pr.body = resp + .get("body") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or(pr.body.clone()); + } + } else { + ctx.warnings + .push("GitHub API: failed to fetch pull request details".to_string()); + } + + let files_url = format!("{base}/pulls/{number}/files?per_page={MAX_CHANGED_FILES}"); + if let Ok(files) = github_get(client, &files_url, token) { + if let Some(arr) = files.as_array() { + let changed: Vec = arr + .iter() + .filter_map(|f| { + Some(CodraChangedFile { + filename: f.get("filename")?.as_str()?.to_string(), + status: f + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("modified") + .to_string(), + additions: f.get("additions").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + deletions: f.get("deletions").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + changes: f.get("changes").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + patch: f.get("patch").and_then(|v| v.as_str()).map(truncate_patch), + }) + }) + .collect(); + if let Some(pr) = &mut ctx.pull_request { + pr.changed_files = Some(changed); + } + } + } else { + ctx.warnings + .push("GitHub API: failed to fetch pull request changed files".to_string()); + } + + if let Some(diff) = fetch_accept( + client, + &format!("{base}/pulls/{number}"), + token, + "application/vnd.github.v3.diff", + ) { + if let Some(pr) = &mut ctx.pull_request { + pr.diff_text = Some(truncate_patch(&diff)); + } + } + + if let Some(sha) = ctx.sha.as_ref().filter(|s| !s.is_empty()) { + let url = format!("{base}/commits/{sha}/check-runs?per_page=30"); + if let Ok(resp) = github_get(client, &url, token) { + ctx.checks = parse_check_runs(&resp); + } + } +} + +fn enrich_issue( + client: &Client, + ctx: &mut CodraGitHubContext, + owner: &str, + repo: &str, + token: &str, + number: u64, +) { + let url = + format!("https://api.github.com/repos/{owner}/{repo}/issues/{number}/comments?per_page=50"); + if let Ok(resp) = github_get(client, &url, token) { + if let Some(arr) = resp.as_array() { + let comments: Vec = arr + .iter() + .map(|c| CodraIssueComment { + author: c + .get("user") + .and_then(|u| u.get("login")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + body: c.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(), + created_at: c + .get("created_at") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + .collect(); + if let Some(issue) = &mut ctx.issue { + issue.comments = Some(comments); + } + } + } else { + ctx.warnings + .push("GitHub API: failed to fetch issue comments".to_string()); + } +} + +fn truncate_patch(p: &str) -> String { + if p.len() <= MAX_PATCH_LEN { + p.to_string() + } else { + format!("{}… [truncated]", &p[..MAX_PATCH_LEN]) + } +} + +fn github_get(client: &Client, url: &str, token: &str) -> Result { + let resp = client + .get(url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "codra-cli") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {token}")) + .send() + .map_err(|_| ())?; + + if !resp.status().is_success() { + return Err(()); + } + resp.json().map_err(|_| ()) +} + +fn fetch_accept(client: &Client, url: &str, token: &str, accept: &str) -> Option { + let resp = client + .get(url) + .header("Accept", accept) + .header("User-Agent", "codra-cli") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {token}")) + .send() + .ok()?; + if !resp.status().is_success() { + return None; + } + resp.text().ok() +} + +fn parse_check_runs(resp: &Value) -> Option> { + let runs = resp.get("check_runs")?.as_array()?; + Some( + runs.iter() + .filter_map(|r| { + Some(CodraCheckSummary { + name: r.get("name").and_then(|v| v.as_str())?.to_string(), + status: r + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + conclusion: r + .get("conclusion") + .and_then(|v| v.as_str()) + .map(str::to_string), + details_url: r + .get("details_url") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + }) + .collect(), + ) +} \ No newline at end of file diff --git a/crates/codra-cli/src/context/local_git.rs b/crates/codra-cli/src/context/local_git.rs new file mode 100644 index 0000000..68e076f --- /dev/null +++ b/crates/codra-cli/src/context/local_git.rs @@ -0,0 +1,61 @@ +use std::process::Command; + +use super::types::LocalGitContext; + +pub fn load_local_git() -> (LocalGitContext, Vec) { + let mut warnings = Vec::new(); + if Command::new("git").arg("--version").output().is_err() { + warnings.push("git is not available; skipping local git metadata".to_string()); + return (LocalGitContext::default(), warnings); + } + + let root = run_git(&["rev-parse", "--show-toplevel"]); + let branch = run_git(&["branch", "--show-current"]); + let status_short = run_git(&["status", "--short"]); + let log = run_git(&["log", "--oneline", "-5"]); + + if root.is_none() { + warnings.push("not inside a git repository".to_string()); + } + + let recent_commits = log.map(|s| { + s.lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect::>() + }); + + ( + LocalGitContext { + root, + branch, + status_short, + recent_commits, + }, + warnings, + ) +} + +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +impl Default for LocalGitContext { + fn default() -> Self { + Self { + root: None, + branch: None, + status_short: None, + recent_commits: None, + } + } +} \ No newline at end of file diff --git a/crates/codra-cli/src/context/mod.rs b/crates/codra-cli/src/context/mod.rs new file mode 100644 index 0000000..b2d050d --- /dev/null +++ b/crates/codra-cli/src/context/mod.rs @@ -0,0 +1,6 @@ +pub mod github; +pub mod local_git; +pub mod types; + +pub use github::load_github_context; +pub use types::CodraGitHubContext; \ No newline at end of file diff --git a/crates/codra-cli/src/context/types.rs b/crates/codra-cli/src/context/types.rs new file mode 100644 index 0000000..b2dbe5d --- /dev/null +++ b/crates/codra-cli/src/context/types.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GitHubContextMode { + Local, + #[serde(rename = "github-actions")] + GitHubActions, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraChangedFile { + pub filename: String, + pub status: String, + pub additions: u32, + pub deletions: u32, + pub changes: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraIssueComment { + pub author: String, + pub body: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraPullRequestContext { + pub number: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub head_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub changed_files: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub diff_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraIssueContext { + pub number: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comments: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraCheckSummary { + pub name: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conclusion: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub details_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalGitContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub root: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_short: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_commits: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodraGitHubContext { + pub available: bool, + pub mode: GitHubContextMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha: Option, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + pub ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub head_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub issue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub checks: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub local_git: Option, + pub warnings: Vec, +} \ No newline at end of file diff --git a/crates/codra-cli/src/events.rs b/crates/codra-cli/src/events.rs new file mode 100644 index 0000000..c099d05 --- /dev/null +++ b/crates/codra-cli/src/events.rs @@ -0,0 +1,142 @@ +use serde::Serialize; +use serde_json::Value; +use std::io::{self, Write}; + +use crate::utils::time::timestamp_rfc3339; + +pub const SOURCE: &str = "codra-cli"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodraEvent { + #[serde(rename = "type")] + pub event_type: String, + pub run_id: String, + pub timestamp: String, + pub task: String, + pub source: String, + pub data: Value, +} + +impl CodraEvent { + pub fn new(event_type: &str, run_id: &str, task: &str, data: Value) -> Self { + Self { + event_type: event_type.to_string(), + run_id: run_id.to_string(), + timestamp: timestamp_rfc3339(), + task: task.to_string(), + source: SOURCE.to_string(), + data, + } + } + + pub fn to_json_line(&self) -> Result { + serde_json::to_string(self).map_err(|e| e.to_string()) + } +} + +pub struct EventEmitter { + pub run_id: String, + pub task: String, + pub jsonl: bool, +} + +impl EventEmitter { + pub fn new(run_id: String, task: String, jsonl: bool) -> Self { + Self { + run_id, + task, + jsonl, + } + } + + pub fn emit(&self, event_type: &str, data: Value) -> Result<(), String> { + let event = CodraEvent::new(event_type, &self.run_id, &self.task, data); + if self.jsonl { + let line = event.to_json_line()?; + println!("{line}"); + io::stdout().flush().map_err(|e| e.to_string())?; + } else { + self.emit_human(&event)?; + } + Ok(()) + } + + pub fn warning(&self, message: impl Into) -> Result<(), String> { + self.emit( + "codra.warning", + serde_json::json!({ "message": message.into() }), + ) + } + + fn emit_human(&self, event: &CodraEvent) -> Result<(), String> { + match event.event_type.as_str() { + "codra.run.started" => println!("▶ Run started ({})", event.run_id), + "codra.context.loading" => println!("… Loading context"), + "codra.context.loaded" => { + let available = event.data.get("available").and_then(|v| v.as_bool()); + println!( + "✓ Context loaded (available: {})", + available.unwrap_or(false) + ); + } + "codra.task.started" => println!("▶ Task: {}", event.task), + "codra.task.summary" => { + if let Some(overview) = event.data.get("overview").and_then(|v| v.as_str()) { + println!("\n{overview}"); + } + if let Some(steps) = event.data.get("nextSuggestedSteps").and_then(|v| v.as_array()) + { + if !steps.is_empty() { + println!("\nSuggested next steps:"); + for step in steps { + if let Some(s) = step.as_str() { + println!(" - {s}"); + } + } + } + } + } + "codra.task.completed" => println!("✓ Task completed"), + "codra.run.completed" => println!("✓ Run completed"), + "codra.run.failed" => { + let msg = event + .data + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + eprintln!("✗ Run failed: {msg}"); + } + "codra.warning" => { + let msg = event + .data + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("warning"); + eprintln!("⚠ {msg}"); + } + other => println!("[{other}]"), + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_serializes_required_fields() { + let event = CodraEvent::new( + "codra.run.started", + "run_test_1", + "review-pr", + serde_json::json!({ "cwd": "/tmp", "jsonl": true }), + ); + let line = event.to_json_line().unwrap(); + assert!(line.contains("\"type\":\"codra.run.started\"")); + assert!(line.contains("\"runId\":\"run_test_1\"") || line.contains("\"runId\": \"run_test_1\"")); + assert!(line.contains("\"source\":\"codra-cli\"")); + assert!(line.contains("\"task\":\"review-pr\"")); + } +} \ No newline at end of file diff --git a/crates/codra-cli/src/lib.rs b/crates/codra-cli/src/lib.rs new file mode 100644 index 0000000..242acd6 --- /dev/null +++ b/crates/codra-cli/src/lib.rs @@ -0,0 +1,7 @@ +pub mod context; +pub mod events; +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 diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index ef97282..bacff25 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -1,3 +1,4 @@ +use codra_cli::run::{parse_run_args, run_task}; use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider}; use codra_core::provider_config::ProviderConfigService; use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind}; @@ -33,6 +34,10 @@ fn main() { .unwrap_or_else(|| "Inspect workspace and report readiness.".to_string()), ), "mcp-server" => mcp_server(), + "run" => { + args.remove(0); + run_command(&args) + } _ => help(), }; @@ -516,8 +521,15 @@ 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"); + println!(" Tasks: review-pr, explain-issue, summarize-context"); println!(" smoke Validate local tool registry and workspace readiness"); println!(" provider check Check active provider health"); println!(" worker add Register a remote worker"); diff --git a/crates/codra-cli/src/run.rs b/crates/codra-cli/src/run.rs new file mode 100644 index 0000000..3c559a2 --- /dev/null +++ b/crates/codra-cli/src/run.rs @@ -0,0 +1,107 @@ +use std::env; + +use serde_json::json; + +use crate::context::load_github_context; +use crate::events::EventEmitter; +use crate::tasks; +use crate::utils::ids::new_run_id; + +pub const VALID_TASKS: &[&str] = &["review-pr", "explain-issue", "summarize-context"]; + +#[derive(Debug, Clone)] +pub struct RunOptions { + pub task: String, + pub jsonl: bool, +} + +pub fn parse_run_args(args: &[String]) -> Result { + let mut task: Option = None; + let mut jsonl = false; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--task" => { + task = args.get(i + 1).cloned(); + i += 2; + } + "--jsonl" => { + jsonl = true; + i += 1; + } + flag if flag.starts_with("--") => { + return Err(format!("unknown flag: {flag}")); + } + other => return Err(format!("unexpected argument: {other}")), + } + } + + let task = task.ok_or_else(|| { + format!( + "missing --task. Valid tasks: {}", + VALID_TASKS.join(", ") + ) + })?; + + if !VALID_TASKS.contains(&task.as_str()) { + return Err(format!( + "invalid task '{task}'. Valid tasks: {}", + VALID_TASKS.join(", ") + )); + } + + Ok(RunOptions { task, jsonl }) +} + +pub fn run_task(opts: RunOptions) -> Result<(), String> { + let cwd = env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let run_id = new_run_id(); + let emitter = EventEmitter::new(run_id.clone(), opts.task.clone(), opts.jsonl); + + emitter.emit( + "codra.run.started", + json!({ "cwd": cwd, "jsonl": opts.jsonl }), + )?; + + emitter.emit("codra.context.loading", json!({}))?; + + let ctx = load_github_context(); + for w in &ctx.warnings { + emitter.warning(w)?; + } + + emitter.emit( + "codra.context.loaded", + json!({ + "available": ctx.available, + "mode": ctx.mode, + "repository": ctx.repository, + "eventName": ctx.event_name, + "hasPullRequest": ctx.pull_request.is_some(), + "hasIssue": ctx.issue.is_some() + }), + )?; + + emitter.emit("codra.task.started", json!({}))?; + + let result = tasks::run_task_handler(&opts.task, &emitter, &ctx); + + match result { + Ok(_) => { + emitter.emit("codra.task.completed", json!({ "success": true }))?; + emitter.emit("codra.run.completed", json!({ "exitCode": 0 }))?; + Ok(()) + } + Err(err) => { + let safe = crate::utils::safe_error::redact_secrets(&err); + emitter.emit( + "codra.run.failed", + json!({ "message": safe, "exitCode": 1 }), + )?; + Err(safe) + } + } +} \ No newline at end of file diff --git a/crates/codra-cli/src/tasks/explain_issue.rs b/crates/codra-cli/src/tasks/explain_issue.rs new file mode 100644 index 0000000..078c881 --- /dev/null +++ b/crates/codra-cli/src/tasks/explain_issue.rs @@ -0,0 +1,48 @@ +use serde_json::{json, Value}; + +use crate::context::CodraGitHubContext; +use crate::events::EventEmitter; + +pub fn run(emitter: &EventEmitter, ctx: &CodraGitHubContext) -> Result { + let summary = if let Some(issue) = &ctx.issue { + let comment_count = issue.comments.as_ref().map(|c| c.len()).unwrap_or(0); + let title = issue.title.as_deref().unwrap_or("(no title)"); + let body_preview = issue + .body + .as_deref() + .unwrap_or("") + .chars() + .take(500) + .collect::(); + + json!({ + "overview": format!( + "Issue #{}: {title}\nAuthor: {}\nComments: {comment_count}\n\n{body_preview}", + issue.number, + issue.author.as_deref().unwrap_or("unknown") + ), + "changedFiles": [], + "riskAreas": if comment_count > 10 { + vec![format!("High discussion volume ({comment_count} comments)")] + } else { + vec![] + }, + "nextSuggestedSteps": [ + "Triage labels and reproduction steps", + "Link related PRs if any", + "Phase 3: post explanation as issue comment via Action" + ] + }) + } else { + emitter.warning("no issue context available")?; + json!({ + "overview": "No GitHub issue context detected. Run in issues or issue_comment workflow, or provide event fixture.", + "changedFiles": [], + "riskAreas": [], + "nextSuggestedSteps": ["Set GITHUB_EVENT_NAME=issues and GITHUB_EVENT_PATH for local testing"] + }) + }; + + emitter.emit("codra.task.summary", summary.clone())?; + Ok(summary) +} \ No newline at end of file diff --git a/crates/codra-cli/src/tasks/mod.rs b/crates/codra-cli/src/tasks/mod.rs new file mode 100644 index 0000000..f19542a --- /dev/null +++ b/crates/codra-cli/src/tasks/mod.rs @@ -0,0 +1,21 @@ +mod explain_issue; +mod review_pr; +mod summarize_context; + +use serde_json::Value; + +use crate::context::CodraGitHubContext; +use crate::events::EventEmitter; + +pub fn run_task_handler( + task: &str, + emitter: &EventEmitter, + ctx: &CodraGitHubContext, +) -> Result { + match task { + "review-pr" => review_pr::run(emitter, ctx), + "explain-issue" => explain_issue::run(emitter, ctx), + "summarize-context" => summarize_context::run(emitter, ctx), + _ => Err(format!("unknown task: {task}")), + } +} \ No newline at end of file diff --git a/crates/codra-cli/src/tasks/review_pr.rs b/crates/codra-cli/src/tasks/review_pr.rs new file mode 100644 index 0000000..20c8254 --- /dev/null +++ b/crates/codra-cli/src/tasks/review_pr.rs @@ -0,0 +1,107 @@ +use serde_json::{json, Value}; + +use crate::context::CodraGitHubContext; +use crate::events::EventEmitter; + +pub fn run(emitter: &EventEmitter, ctx: &CodraGitHubContext) -> Result { + let summary = if let Some(pr) = &ctx.pull_request { + build_pr_summary(pr) + } else { + emitter.warning("no pull request context available; using local repo summary")?; + build_local_summary(ctx) + }; + + emitter.emit("codra.task.summary", summary.clone())?; + Ok(summary) +} + +fn build_pr_summary(pr: &crate::context::types::CodraPullRequestContext) -> Value { + let title = pr.title.as_deref().unwrap_or("(no title)"); + let file_count = pr.changed_files.as_ref().map(|f| f.len()).unwrap_or(0); + let (additions, deletions) = pr + .changed_files + .as_ref() + .map(|files| { + files.iter().fold((0u32, 0u32), |(a, d), f| { + (a + f.additions, d + f.deletions) + }) + }) + .unwrap_or((0, 0)); + + let changed_files: Vec = pr + .changed_files + .as_ref() + .map(|files| { + files + .iter() + .take(20) + .map(|f| format!("{} ({})", f.filename, f.status)) + .collect() + }) + .unwrap_or_default(); + + let risk_areas = infer_risk_areas(pr); + + json!({ + "overview": format!( + "PR #{}: {title}\nChanged files: {file_count} (+{additions}/-{deletions})", + pr.number + ), + "changedFiles": changed_files, + "riskAreas": risk_areas, + "nextSuggestedSteps": [ + "Review high-churn files and test coverage", + "Run local tests against head ref", + "Phase 3: wire GitHub Action to post this summary as a PR comment" + ] + }) +} + +fn build_local_summary(ctx: &CodraGitHubContext) -> Value { + let branch = ctx + .local_git + .as_ref() + .and_then(|g| g.branch.as_deref()) + .unwrap_or("unknown"); + let root = ctx + .local_git + .as_ref() + .and_then(|g| g.root.as_deref()) + .unwrap_or("unknown"); + + json!({ + "overview": format!("Local repo at {root} on branch {branch} (no PR context)"), + "changedFiles": [], + "riskAreas": ["No PR diff available in local mode"], + "nextSuggestedSteps": [ + "Run inside GitHub Actions with pull_request event for PR review", + "Or set GITHUB_EVENT_PATH to a fixture for local testing" + ] + }) +} + +fn infer_risk_areas(pr: &crate::context::types::CodraPullRequestContext) -> Vec { + let mut risks = Vec::new(); + if let Some(files) = &pr.changed_files { + if files.len() > 30 { + risks.push(format!("Large change set ({} files)", files.len())); + } + for f in files { + let name = f.filename.to_lowercase(); + if name.contains("cargo.toml") + || name.contains("package.json") + || name.contains("pnpm-lock") + || name.contains("dockerfile") + { + risks.push(format!("Dependency or build config touched: {}", f.filename)); + } + if name.contains("migration") || name.ends_with(".sql") { + risks.push(format!("Database migration: {}", f.filename)); + } + } + } + if risks.is_empty() { + risks.push("No elevated risk signals detected (deterministic scan)".to_string()); + } + risks +} \ No newline at end of file diff --git a/crates/codra-cli/src/tasks/summarize_context.rs b/crates/codra-cli/src/tasks/summarize_context.rs new file mode 100644 index 0000000..7eed242 --- /dev/null +++ b/crates/codra-cli/src/tasks/summarize_context.rs @@ -0,0 +1,39 @@ +use serde_json::{json, Value}; + +use crate::context::CodraGitHubContext; +use crate::events::EventEmitter; + +pub fn run(emitter: &EventEmitter, ctx: &CodraGitHubContext) -> Result { + let mode = serde_json::to_value(&ctx.mode).unwrap_or(json!("local")); + let summary = json!({ + "overview": format!( + "Context mode: {mode}\nAvailable: {}\nRepository: {}\nEvent: {}", + ctx.available, + ctx.repository.as_deref().unwrap_or("n/a"), + ctx.event_name.as_deref().unwrap_or("n/a") + ), + "changedFiles": [], + "riskAreas": [], + "nextSuggestedSteps": ["Use --jsonl for machine-readable full context in data field"], + "context": ctx + }); + + emitter.emit( + "codra.task.summary", + json!({ + "overview": summary.get("overview"), + "changedFiles": [], + "riskAreas": [], + "nextSuggestedSteps": summary.get("nextSuggestedSteps"), + "detected": { + "mode": mode, + "available": ctx.available, + "hasPullRequest": ctx.pull_request.is_some(), + "hasIssue": ctx.issue.is_some(), + "warningCount": ctx.warnings.len(), + "localGitRoot": ctx.local_git.as_ref().and_then(|g| g.root.clone()) + } + }), + )?; + Ok(summary) +} \ No newline at end of file diff --git a/crates/codra-cli/src/utils/ids.rs b/crates/codra-cli/src/utils/ids.rs new file mode 100644 index 0000000..10986b2 --- /dev/null +++ b/crates/codra-cli/src/utils/ids.rs @@ -0,0 +1,17 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn new_run_id() -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("run_{ms}_{}", random_suffix()) +} + +fn random_suffix() -> u32 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + nanos % 1_000_000 +} \ No newline at end of file diff --git a/crates/codra-cli/src/utils/mod.rs b/crates/codra-cli/src/utils/mod.rs new file mode 100644 index 0000000..82dfbfe --- /dev/null +++ b/crates/codra-cli/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod ids; +pub mod safe_error; +pub mod time; \ No newline at end of file diff --git a/crates/codra-cli/src/utils/safe_error.rs b/crates/codra-cli/src/utils/safe_error.rs new file mode 100644 index 0000000..5eb2f5e --- /dev/null +++ b/crates/codra-cli/src/utils/safe_error.rs @@ -0,0 +1,14 @@ +/// Redact values that must never appear in logs or warnings. +pub fn redact_secrets(message: &str) -> String { + let mut out = message.to_string(); + for marker in ["ghp_", "github_pat_", "Bearer ", "Authorization:"] { + if let Some(idx) = out.find(marker) { + let end = out[idx..] + .find(|c: char| c.is_whitespace() || c == '\n') + .map(|i| idx + i) + .unwrap_or(out.len()); + out.replace_range(idx..end, "[REDACTED]"); + } + } + out +} \ No newline at end of file diff --git a/crates/codra-cli/src/utils/time.rs b/crates/codra-cli/src/utils/time.rs new file mode 100644 index 0000000..808bc92 --- /dev/null +++ b/crates/codra-cli/src/utils/time.rs @@ -0,0 +1,5 @@ +use chrono::Utc; + +pub fn timestamp_rfc3339() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} \ No newline at end of file diff --git a/crates/codra-cli/tests/fixtures/pull_request_event.json b/crates/codra-cli/tests/fixtures/pull_request_event.json new file mode 100644 index 0000000..e4bf206 --- /dev/null +++ b/crates/codra-cli/tests/fixtures/pull_request_event.json @@ -0,0 +1,15 @@ +{ + "action": "opened", + "number": 42, + "pull_request": { + "number": 42, + "title": "feat: example PR", + "body": "Implements example changes for testing.", + "user": { "login": "octocat" }, + "base": { "ref": "main" }, + "head": { "ref": "feat/example" } + }, + "repository": { + "full_name": "talocode/codra" + } +} \ No newline at end of file diff --git a/crates/codra-cli/tests/github_context_test.rs b/crates/codra-cli/tests/github_context_test.rs new file mode 100644 index 0000000..cc065e2 --- /dev/null +++ b/crates/codra-cli/tests/github_context_test.rs @@ -0,0 +1,40 @@ +use codra_cli::context::github::parse_event_payload_from_str; +use codra_cli::context::types::{CodraGitHubContext, GitHubContextMode}; + +#[test] +fn parses_pull_request_fixture() { + let raw = include_str!("fixtures/pull_request_event.json"); + let mut ctx = CodraGitHubContext { + available: false, + mode: GitHubContextMode::Local, + repository: Some("talocode/codra".to_string()), + owner: Some("talocode".to_string()), + repo: Some("codra".to_string()), + event_name: None, + event_path: None, + sha: None, + ref_name: None, + base_ref: Some("main".to_string()), + head_ref: Some("feat/example".to_string()), + pull_request: None, + issue: None, + checks: None, + local_git: None, + warnings: Vec::new(), + }; + + parse_event_payload_from_str(&mut ctx, "pull_request", raw); + + let pr = ctx.pull_request.expect("pull request context"); + assert_eq!(pr.number, 42); + assert_eq!(pr.title.as_deref(), Some("feat: example PR")); + assert_eq!(pr.author.as_deref(), Some("octocat")); + assert!(ctx.available); + assert_eq!(ctx.mode, GitHubContextMode::Local); +} + +#[test] +fn no_token_does_not_panic() { + let ctx = codra_cli::context::load_github_context(); + assert!(!ctx.warnings.iter().any(|w| w.contains("ghp_"))); +} \ 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 new file mode 100644 index 0000000..4cd0d70 --- /dev/null +++ b/crates/codra-cli/tests/run_args_test.rs @@ -0,0 +1,24 @@ +use codra_cli::parse_run_args; +use codra_cli::VALID_TASKS; + +#[test] +fn parses_task_and_jsonl_flag() { + let args = vec![ + "--task".to_string(), + "review-pr".to_string(), + "--jsonl".to_string(), + ]; + let opts = parse_run_args(&args).unwrap(); + assert_eq!(opts.task, "review-pr"); + assert!(opts.jsonl); +} + +#[test] +fn rejects_invalid_task() { + let args = vec!["--task".to_string(), "not-a-task".to_string()]; + let err = parse_run_args(&args).unwrap_err(); + assert!(err.contains("invalid task")); + for t in VALID_TASKS { + assert!(err.contains(t)); + } +} \ No newline at end of file