From 7aa80fb0a423bc1403fbc148444915e7496ebffa Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 26 Oct 2025 05:49:21 +0100 Subject: [PATCH] feat: add repo automation workflows --- README.md | 10 + agents.md | 2 +- crates/owlen-cli/src/commands/mod.rs | 1 + crates/owlen-cli/src/commands/repo.rs | 203 +++++ crates/owlen-cli/src/main.rs | 5 + crates/owlen-core/src/automation/mod.rs | 9 + crates/owlen-core/src/automation/repo.rs | 943 +++++++++++++++++++++++ crates/owlen-core/src/github.rs | 258 +++++++ crates/owlen-core/src/lib.rs | 4 + crates/owlen-tui/src/chat_app.rs | 178 +++++ crates/owlen-tui/src/commands/mod.rs | 27 + docs/configuration.md | 9 + docs/troubleshooting.md | 6 + 13 files changed, 1654 insertions(+), 1 deletion(-) create mode 100644 crates/owlen-cli/src/commands/repo.rs create mode 100644 crates/owlen-core/src/automation/mod.rs create mode 100644 crates/owlen-core/src/automation/repo.rs create mode 100644 crates/owlen-core/src/github.rs diff --git a/README.md b/README.md index 2d8f309..3dd8dce 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ The refreshed chrome introduces a cockpit-style header with live gradient gauges - **Non-Blocking UI Loop**: Asynchronous generation tasks and provider health checks run off-thread, keeping the TUI responsive even while streaming long replies. - **Guided Setup**: `owlen config doctor` upgrades legacy configs and verifies your environment in seconds. +## Repository Automation + +Owlen now ships with Git-aware automation helpers so you can review code and stage commits without leaving the terminal: + +- **CLI** – `owlen repo commit-template` renders a conventional commit scaffolding from the staged diff (`--working-tree` inspects unstaged changes), while `owlen repo review` summarises the current branch or a GitHub pull request. Provide `--owner`, `--repo`, and `--number` to fetch remote diffs; the command picks up credentials from `GITHUB_TOKEN` (override with `--token-env` or `--token`). +- **TUI** – `:repo template` injects the generated template into the conversation stream, and `:repo review [--base BRANCH] [--head REF]` produces a Markdown review of local changes. The results appear as system messages so you can follow up with an LLM turn or copy them directly into a GitHub comment. +- **Automation APIs** – Under the hood, `owlen-core::automation::repo` exposes reusable builders (`RepoAutomation`, `CommitTemplate`, `PullRequestReview`) that mirror the Claude Code workflow style. They provide JSON-serialisable checklists, workflow steps, and heuristics that highlight risky changes (e.g., new `unwrap()` calls, unchecked `unsafe` blocks, or absent tests). + +Add a personal access token with `repo` scope to unlock GitHub diff fetching. Enterprise installations can point at a custom API host with the `--api-endpoint` flag. + ## Upgrading to v0.2 - **Local + Cloud resiliency**: Owlen now distinguishes the on-device daemon from Ollama Cloud and gracefully falls back to local if the hosted key is missing or unauthorized. Cloud requests include `Authorization: Bearer ` and reuse the canonical `https://ollama.com` base URL so you no longer hit 401 loops. diff --git a/agents.md b/agents.md index 43615b3..0e91a28 100644 --- a/agents.md +++ b/agents.md @@ -1,7 +1,7 @@ # Agents Upgrade Plan - [x] feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Code’s artifact outputs -- feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation +- [x] feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLI’s GitHub integration and Claude Code’s CLI/SDK automation - feat: implement Codex-style non-blocking TUI so commands remain usable while backend work runs: 1. Add an `AppEvent` channel and dispatch layer in `crates/owlen-tui/src/app/mod.rs` that mirrors the `tokio::select!` loop used in `codex-rs/tui/src/app.rs:190-197` to multiplex UI input, session events, and background updates without blocking redraws. 2. Refactor `ChatApp::process_pending_llm_request` and related helpers to spawn tasks that submit prompts via `SessionController` and stream results back through the new channel, following `codex-rs/tui/src/chatwidget/agent.rs:16-61` so the request lifecycle no longer stalls the UI thread. diff --git a/crates/owlen-cli/src/commands/mod.rs b/crates/owlen-cli/src/commands/mod.rs index daaab95..09a7816 100644 --- a/crates/owlen-cli/src/commands/mod.rs +++ b/crates/owlen-cli/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod cloud; pub mod providers; +pub mod repo; pub mod security; pub mod tools; diff --git a/crates/owlen-cli/src/commands/repo.rs b/crates/owlen-cli/src/commands/repo.rs new file mode 100644 index 0000000..b7dbbba --- /dev/null +++ b/crates/owlen-cli/src/commands/repo.rs @@ -0,0 +1,203 @@ +use std::env; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow}; +use clap::{Args, Subcommand, ValueEnum}; +use owlen_core::automation::repo::{ + CommitTemplate, DiffCaptureMode, PullRequestContext, PullRequestReview, RepoAutomation, + summarize_diff, +}; +use owlen_core::github::{GithubClient, GithubConfig}; + +/// Subcommands for repository automation helpers (commit templates, PR reviews, workflows). +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Subcommand)] +pub enum RepoCommand { + /// Generate a conventional commit template from repository changes. + CommitTemplate(CommitTemplateArgs), + /// Produce a structured review for a pull request or local diff. + Review(ReviewArgs), +} + +#[derive(Debug, Args)] +pub struct CommitTemplateArgs { + /// Repository path (defaults to current directory). + #[arg(long, value_name = "PATH")] + pub repo: Option, + /// Output format for the generated template. + #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)] + pub format: OutputFormat, + /// Include unstaged working tree changes instead of staged changes. + #[arg(long)] + pub working_tree: bool, +} + +#[derive(Debug, Args)] +pub struct ReviewArgs { + /// Repository path for local diff analysis. + #[arg(long, value_name = "PATH")] + pub repo: Option, + /// Base ref for local diff review (default: origin/main). + #[arg(long)] + pub base: Option, + /// Head ref for local diff review (default: HEAD). + #[arg(long)] + pub head: Option, + /// Owner of the GitHub repository. + #[arg(long)] + pub owner: Option, + /// Repository name on GitHub. + #[arg(long = "repo")] + pub repository: Option, + /// Pull request number to fetch from GitHub. + #[arg(long)] + pub number: Option, + /// GitHub personal access token (falls back to environment variable). + #[arg(long)] + pub token: Option, + /// Environment variable used to resolve the GitHub token. + #[arg(long, default_value = "GITHUB_TOKEN")] + pub token_env: String, + /// Custom GitHub API endpoint (for GitHub Enterprise). + #[arg(long)] + pub api_endpoint: Option, + /// Path to a diff file to analyse instead of hitting Git or GitHub. + #[arg(long, value_name = "FILE")] + pub diff_file: Option, + /// Output format for the review body. + #[arg(long, value_enum, default_value_t = OutputFormat::Markdown)] + pub format: OutputFormat, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum OutputFormat { + Text, + Markdown, + Json, +} + +pub async fn run_repo_command(command: RepoCommand) -> Result<()> { + match command { + RepoCommand::CommitTemplate(args) => handle_commit_template(args).await, + RepoCommand::Review(args) => handle_review(args).await, + } +} + +async fn handle_commit_template(args: CommitTemplateArgs) -> Result<()> { + let repo_hint = args.repo.clone().unwrap_or_else(|| PathBuf::from(".")); + let automation = RepoAutomation::from_path(&repo_hint)?; + let mode = if args.working_tree { + DiffCaptureMode::WorkingTree + } else { + DiffCaptureMode::Staged + }; + let template = automation.generate_commit_template(mode)?; + emit_commit_template(&template, args.format); + Ok(()) +} + +async fn handle_review(args: ReviewArgs) -> Result<()> { + if let Some(number) = args.number { + let owner = args + .owner + .as_deref() + .ok_or_else(|| anyhow!("--owner is required when --number is provided"))?; + let repo = args + .repository + .as_deref() + .ok_or_else(|| anyhow!("--repo is required when --number is provided"))?; + let token = args + .token + .or_else(|| env::var(&args.token_env).ok()) + .filter(|value| !value.trim().is_empty()); + let client = GithubClient::new(GithubConfig { + token, + api_endpoint: args.api_endpoint.clone(), + })?; + let details = client.pull_request(owner, repo, number).await?; + let review = PullRequestReview::from_diff(details.context, &details.diff); + emit_review_output(review, args.format); + return Ok(()); + } + + if let Some(path) = args.diff_file.as_ref() { + let diff = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read diff file {}", path.display()))?; + let stats = summarize_diff(&diff); + let diff_label = path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.display().to_string()); + let context = PullRequestContext { + title: format!("Review for diff from {}", diff_label), + body: None, + author: None, + base_branch: args + .base + .clone() + .unwrap_or_else(|| "(unknown base)".to_string()), + head_branch: args.head.clone().unwrap_or_else(|| "(diff)".to_string()), + additions: stats.additions as u64, + deletions: stats.deletions as u64, + changed_files: stats.files as u64, + html_url: None, + }; + let review = PullRequestReview::from_diff(context, &diff); + emit_review_output(review, args.format); + return Ok(()); + } + + let repo_hint = args.repo.clone().unwrap_or_else(|| PathBuf::from(".")); + let automation = RepoAutomation::from_path(&repo_hint)?; + let review = automation.generate_pr_review(args.base.as_deref(), args.head.as_deref())?; + emit_review_output(review, args.format); + Ok(()) +} + +fn emit_commit_template(template: &CommitTemplate, format: OutputFormat) { + match format { + OutputFormat::Markdown => { + println!("{}", template.render_markdown()); + } + OutputFormat::Text => { + let markdown = template.render_markdown(); + for line in markdown.lines() { + println!("{}", line.trim_start_matches('-').trim()); + } + } + OutputFormat::Json => match serde_json::to_string_pretty(template) { + Ok(json) => println!("{}", json), + Err(err) => eprintln!("Failed to encode template as JSON: {}", err), + }, + } +} + +fn emit_review_output(review: PullRequestReview, format: OutputFormat) { + match format { + OutputFormat::Markdown => println!("{}", review.render_markdown()), + OutputFormat::Text => { + println!("{}", review.summary); + for highlight in review.highlights { + println!("* {}", highlight); + } + if !review.findings.is_empty() { + println!("Findings:"); + for finding in review.findings { + println!(" - [{}] {}", finding.severity, finding.message); + } + } + if !review.checklist.is_empty() { + println!("Checklist:"); + for item in review.checklist { + let mark = if item.completed { "x" } else { " " }; + println!(" - [{}] {}", mark, item.label); + } + } + } + OutputFormat::Json => match serde_json::to_string_pretty(&review) { + Ok(json) => println!("{}", json), + Err(err) => eprintln!("Failed to encode review as JSON: {}", err), + }, + } +} diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index 03f5f9b..0b5b2b9 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -11,6 +11,7 @@ use clap::{Parser, Subcommand}; use commands::{ cloud::{CloudCommand, run_cloud_command}, providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, + repo::{RepoCommand, run_repo_command}, security::{SecurityCommand, run_security_command}, tools::{ToolsCommand, run_tools_command}, }; @@ -64,6 +65,9 @@ enum OwlenCommand { /// Configure security and approval policies #[command(subcommand)] Security(SecurityCommand), + /// Repository automation helpers (commit templates, PR reviews) + #[command(subcommand)] + Repo(RepoCommand), /// Show manual steps for updating Owlen to the latest revision Upgrade, } @@ -91,6 +95,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> { OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), OwlenCommand::Security(sec_cmd) => run_security_command(sec_cmd), + OwlenCommand::Repo(repo_cmd) => run_repo_command(repo_cmd).await, OwlenCommand::Upgrade => { println!( "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" diff --git a/crates/owlen-core/src/automation/mod.rs b/crates/owlen-core/src/automation/mod.rs new file mode 100644 index 0000000..efe7b15 --- /dev/null +++ b/crates/owlen-core/src/automation/mod.rs @@ -0,0 +1,9 @@ +//! High-level automation APIs for repository workflows (commit templating, PR review, etc.). + +pub mod repo; + +pub use repo::{ + CommitTemplate, CommitTemplateSection, DiffCaptureMode, DiffStatistics, FileChange, + PullRequestContext, PullRequestReview, RepoAutomation, ReviewChecklistItem, ReviewFinding, + ReviewSeverity, WorkflowStep, +}; diff --git a/crates/owlen-core/src/automation/repo.rs b/crates/owlen-core/src/automation/repo.rs new file mode 100644 index 0000000..472c310 --- /dev/null +++ b/crates/owlen-core/src/automation/repo.rs @@ -0,0 +1,943 @@ +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::str; + +/// Controls which diff snapshot should be inspected. +#[derive(Debug, Clone, Copy)] +pub enum DiffCaptureMode<'a> { + /// Inspect staged changes (`git diff --cached`). + Staged, + /// Inspect unstaged working-tree changes (`git diff`). + WorkingTree, + /// Inspect the diff between two refs. + Range { base: &'a str, head: &'a str }, +} + +/// High-level automation entry-point for repository-centric workflows. +pub struct RepoAutomation { + repo_root: PathBuf, +} + +impl RepoAutomation { + /// Discover the git repository root starting from the provided path. + pub fn from_path(path: impl AsRef) -> Result { + let root = discover_repo_root(path.as_ref())?; + Ok(Self { repo_root: root }) + } + + /// Return the repository root on disk. + pub fn repo_root(&self) -> &Path { + &self.repo_root + } + + /// Generate a conventional commit template from the selected diff snapshot. + pub fn generate_commit_template(&self, mode: DiffCaptureMode<'_>) -> Result { + let diff = capture_diff(&self.repo_root, mode)?; + if diff.trim().is_empty() { + return Err(Error::InvalidInput( + "No changes detected for the selected diff snapshot.".to_string(), + )); + } + Ok(CommitTemplate::from_diff(&diff)) + } + + /// Produce a pull-request style review for the given range of commits. + pub fn generate_pr_review( + &self, + base: Option<&str>, + head: Option<&str>, + ) -> Result { + let head = head.unwrap_or("HEAD"); + let base = base.unwrap_or("origin/main"); + let merge_base = resolve_merge_base(&self.repo_root, base, head)?; + let diff = capture_range_diff(&self.repo_root, &merge_base, head)?; + if diff.trim().is_empty() { + return Err(Error::InvalidInput( + "The computed diff between the selected refs is empty.".to_string(), + )); + } + let stats = DiffStatistics::from_diff(&diff); + let context = PullRequestContext { + title: format!("Diff of {head} vs {base}"), + body: None, + author: None, + base_branch: base.to_string(), + head_branch: head.to_string(), + additions: stats.additions as u64, + deletions: stats.deletions as u64, + changed_files: stats.files as u64, + html_url: None, + }; + Ok(PullRequestReview::from_diff(context, &diff)) + } +} + +/// Summarised information about a changed file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileChange { + pub old_path: String, + pub new_path: String, + pub change: ChangeKind, + pub additions: usize, + pub deletions: usize, +} + +impl FileChange { + pub fn primary_path(&self) -> &str { + if !self.new_path.is_empty() { + &self.new_path + } else if !self.old_path.is_empty() { + &self.old_path + } else { + "" + } + } + + pub fn is_test(&self) -> bool { + FILE_TEST_HINTS + .iter() + .any(|hint| self.primary_path().contains(hint)) + } + + pub fn is_doc(&self) -> bool { + DOC_EXTENSIONS + .iter() + .any(|ext| self.primary_path().ends_with(ext)) + || self.primary_path().starts_with("docs/") + } + + pub fn is_config(&self) -> bool { + CONFIG_EXTENSIONS + .iter() + .any(|ext| self.primary_path().ends_with(ext)) + } + + pub fn is_code(&self) -> bool { + !self.is_doc() && !self.is_config() + } +} + +/// Change classification for a diff entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChangeKind { + Added, + Removed, + Modified, + Renamed { from: String }, +} + +/// Structured conventional commit template recommendation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitTemplate { + pub prefix: String, + pub summary: Vec, + pub sections: Vec, + pub workflow: Vec, +} + +impl CommitTemplate { + pub fn from_diff(diff: &str) -> Self { + let changes = parse_file_changes(diff); + let metrics = DiffMetrics::from_changes(&changes); + let prefix = select_conventional_prefix(&metrics); + let summary = changes.iter().map(format_change_summary).collect(); + let sections = build_commit_sections(&metrics); + let workflow = vec![ + WorkflowStep::new( + "Parse diff", + format!("Identified {} files", metrics.changed_files), + ), + WorkflowStep::new( + "Choose conventional prefix", + format!("Selected `{}` based on touched domains", prefix), + ), + WorkflowStep::new("Assemble testing checklist", testing_summary(§ions)), + ]; + + Self { + prefix: prefix.to_string(), + summary, + sections, + workflow, + } + } + + /// Render the template as Markdown. + pub fn render_markdown(&self) -> String { + let mut out = String::new(); + out.push_str(&format!("{} \n\n", self.prefix)); + if !self.summary.is_empty() { + out.push_str("Summary:\n"); + for line in &self.summary { + out.push_str("- "); + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + + for section in &self.sections { + out.push_str(&format!("{}:\n", section.title)); + for line in §ion.lines { + out.push_str("- "); + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + + out.trim_end().to_string() + } +} + +/// A named block of checklist items in the commit template. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitTemplateSection { + pub title: String, + pub lines: Vec, +} + +/// Metadata about a pull request / change range. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestContext { + pub title: String, + pub body: Option, + pub author: Option, + pub base_branch: String, + pub head_branch: String, + pub additions: u64, + pub deletions: u64, + pub changed_files: u64, + pub html_url: Option, +} + +/// Markdown-ready automation review artifact. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestReview { + pub context: PullRequestContext, + pub summary: String, + pub highlights: Vec, + pub findings: Vec, + pub checklist: Vec, + pub workflow: Vec, +} + +impl PullRequestReview { + pub fn from_diff(context: PullRequestContext, diff: &str) -> Self { + let changes = parse_file_changes(diff); + let metrics = DiffMetrics::from_changes(&changes); + let highlights = build_highlights(&changes, &metrics); + let findings = analyze_findings(diff, &changes, &metrics); + let checklist = build_review_checklist(&metrics, &changes); + let summary = format!( + "{} files touched (+{}, -{}) · base {} → head {}", + metrics.changed_files, + metrics.total_additions, + metrics.total_deletions, + context.base_branch, + context.head_branch + ); + let workflow = vec![ + WorkflowStep::new( + "Collect diff metadata", + format!( + "{} files, {} additions, {} deletions", + metrics.changed_files, metrics.total_additions, metrics.total_deletions + ), + ), + WorkflowStep::new( + "Assess risk", + format!( + "{} potential issues detected", + findings + .iter() + .filter(|finding| finding.severity != ReviewSeverity::Info) + .count() + ), + ), + WorkflowStep::new( + "Prepare checklist", + format!("{} follow-up items surfaced", checklist.len()), + ), + ]; + + Self { + context, + summary, + highlights, + findings, + checklist, + workflow, + } + } + + /// Render a Markdown review body with sections for highlights, findings, and checklists. + pub fn render_markdown(&self) -> String { + let mut out = String::new(); + out.push_str(&format!("### Summary\n{}\n\n", self.summary)); + + if !self.highlights.is_empty() { + out.push_str("### Highlights\n"); + for highlight in &self.highlights { + out.push_str("- "); + out.push_str(highlight); + out.push('\n'); + } + out.push('\n'); + } + + if !self.findings.is_empty() { + out.push_str("### Findings\n"); + for finding in &self.findings { + out.push_str(&format!( + "- **{}**: {}\n", + finding.severity.label(), + finding.message + )); + if !finding.locations.is_empty() { + for loc in &finding.locations { + out.push_str(" - "); + out.push_str(loc); + out.push('\n'); + } + } + } + out.push('\n'); + } + + if !self.checklist.is_empty() { + out.push_str("### Checklist\n"); + for item in &self.checklist { + let box_mark = if item.completed { "[x]" } else { "[ ]" }; + out.push_str(&format!("- {} {}\n", box_mark, item.label)); + } + out.push('\n'); + } + + out.trim_end().to_string() + } +} + +/// Individual review finding surfaced during heuristics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReviewFinding { + pub severity: ReviewSeverity, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub locations: Vec, +} + +impl ReviewFinding { + fn new(severity: ReviewSeverity, message: impl Into) -> Self { + Self { + severity, + message: message.into(), + locations: Vec::new(), + } + } + + fn with_location(mut self, location: impl Into) -> Self { + self.locations.push(location.into()); + self + } +} + +/// Severity classification for review findings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ReviewSeverity { + Info, + Low, + Medium, + High, +} + +impl ReviewSeverity { + pub fn label(&self) -> &'static str { + match self { + ReviewSeverity::Info => "info", + ReviewSeverity::Low => "low", + ReviewSeverity::Medium => "medium", + ReviewSeverity::High => "high", + } + } +} + +impl fmt::Display for ReviewSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +/// Checklist item exposed in reviews. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReviewChecklistItem { + pub label: String, + pub completed: bool, +} + +impl ReviewChecklistItem { + fn new(label: impl Into, completed: bool) -> Self { + Self { + label: label.into(), + completed, + } + } +} + +/// High-level workflow steps surfaced for SDK-style automation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStep { + pub label: String, + pub outcome: String, +} + +impl WorkflowStep { + pub fn new(label: impl Into, outcome: impl Into) -> Self { + Self { + label: label.into(), + outcome: outcome.into(), + } + } +} + +/// Aggregate diff metrics derived from parsed file changes. +#[derive(Debug, Default, Clone)] +struct DiffMetrics { + changed_files: usize, + total_additions: usize, + total_deletions: usize, + test_files: usize, + doc_files: usize, + config_files: usize, + code_files: usize, +} + +/// Summary statistics extracted from a diff. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffStatistics { + pub files: usize, + pub additions: usize, + pub deletions: usize, +} + +impl DiffStatistics { + pub fn from_diff(diff: &str) -> Self { + Self { + files: count_files(diff), + additions: count_symbol(diff, '+'), + deletions: count_symbol(diff, '-'), + } + } +} + +/// Convenience helper that converts a diff into aggregate statistics. +pub fn summarize_diff(diff: &str) -> DiffStatistics { + DiffStatistics::from_diff(diff) +} + +impl DiffMetrics { + fn from_changes(changes: &[FileChange]) -> Self { + let mut metrics = DiffMetrics { + changed_files: changes.len(), + ..DiffMetrics::default() + }; + for change in changes { + metrics.total_additions += change.additions; + metrics.total_deletions += change.deletions; + if change.is_test() { + metrics.test_files += 1; + } else if change.is_doc() { + metrics.doc_files += 1; + } else if change.is_config() { + metrics.config_files += 1; + } else { + metrics.code_files += 1; + } + } + metrics + } + + fn has_tests(&self) -> bool { + self.test_files > 0 + } + + fn has_docs(&self) -> bool { + self.doc_files > 0 + } +} + +// ------------------------------------------------------------------------------------------------- +// Diff parsing and heuristics +// ------------------------------------------------------------------------------------------------- + +fn parse_file_changes(diff: &str) -> Vec { + let mut changes = Vec::new(); + let mut current: Option = None; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + if let Some(change) = current.take() { + changes.push(change); + } + let mut parts = line.split_whitespace().skip(2); + let old = parts.next().unwrap_or("a/unknown"); + let new = parts.next().unwrap_or("b/unknown"); + current = Some(FileChange { + old_path: strip_path_prefix(old, "a/"), + new_path: strip_path_prefix(new, "b/"), + change: ChangeKind::Modified, + additions: 0, + deletions: 0, + }); + } else if let Some(change) = current.as_mut() { + if line.starts_with("new file mode") { + change.change = ChangeKind::Added; + } else if line.starts_with("deleted file mode") { + change.change = ChangeKind::Removed; + } else if line.starts_with("rename from ") { + let from = line.trim_start_matches("rename from ").trim(); + change.change = ChangeKind::Renamed { + from: strip_path_prefix(from, ""), + }; + change.old_path = strip_path_prefix(from, ""); + } else if line.starts_with("rename to ") { + let to = line.trim_start_matches("rename to ").trim(); + change.new_path = strip_path_prefix(to, ""); + } else if line.starts_with("--- ") { + let old = line.trim_start_matches("--- ").trim(); + if old.starts_with('a') { + change.old_path = strip_path_prefix(old, "a/"); + } + } else if line.starts_with("+++ ") { + let new = line.trim_start_matches("+++ ").trim(); + if new.starts_with('b') { + change.new_path = strip_path_prefix(new, "b/"); + } + } else if line.starts_with('+') && !line.starts_with("+++") { + change.additions += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + change.deletions += 1; + } + } + } + + if let Some(change) = current.take() { + changes.push(change); + } + + changes +} + +fn format_change_summary(change: &FileChange) -> String { + match &change.change { + ChangeKind::Added => format!("add {} (+{})", change.primary_path(), change.additions), + ChangeKind::Removed => format!("remove {} (-{})", change.primary_path(), change.deletions), + ChangeKind::Renamed { from } => format!( + "rename {} → {} (+{}, -{})", + from, + change.primary_path(), + change.additions, + change.deletions + ), + ChangeKind::Modified => format!( + "update {} (+{}, -{})", + change.primary_path(), + change.additions, + change.deletions + ), + } +} + +fn select_conventional_prefix(metrics: &DiffMetrics) -> &'static str { + if metrics.changed_files == 0 { + return "chore:"; + } + if metrics.doc_files > 0 && metrics.code_files == 0 && metrics.test_files == 0 { + "docs:" + } else if metrics.test_files > 0 && metrics.code_files == 0 && metrics.doc_files == 0 { + "test:" + } else if metrics.config_files > 0 && metrics.code_files == 0 { + "chore:" + } else if metrics.total_deletions > metrics.total_additions + && metrics.doc_files == 0 + && metrics.test_files == 0 + { + "refactor:" + } else { + "feat:" + } +} + +fn build_commit_sections(metrics: &DiffMetrics) -> Vec { + let mut sections = Vec::new(); + let tests_label = if metrics.has_tests() { "[x]" } else { "[ ]" }; + let docs_label = if metrics.has_docs() { "[x]" } else { "[ ]" }; + + sections.push(CommitTemplateSection { + title: "Testing".to_string(), + lines: vec![ + format!("{} unit tests", tests_label), + format!("{} integration / e2e", tests_label), + "[ ] lint / fmt".to_string(), + ], + }); + + sections.push(CommitTemplateSection { + title: "Documentation".to_string(), + lines: vec![ + format!("{} docs updated", docs_label), + "[ ] release notes".to_string(), + ], + }); + + sections +} + +fn testing_summary(sections: &[CommitTemplateSection]) -> String { + sections + .iter() + .flat_map(|section| §ion.lines) + .filter(|line| line.contains("tests")) + .cloned() + .collect::>() + .join(", ") +} + +fn build_highlights(changes: &[FileChange], metrics: &DiffMetrics) -> Vec { + let mut highlights = Vec::new(); + if metrics.code_files > 0 { + highlights.push(format!( + "{} code files modified (+{}, -{})", + metrics.code_files, metrics.total_additions, metrics.total_deletions + )); + } + if metrics.has_tests() { + highlights.push(format!("{} test files updated", metrics.test_files)); + } else if metrics.code_files > 0 { + highlights.push("No test files updated; consider adding coverage.".to_string()); + } + if metrics.has_docs() { + highlights.push(format!("{} documentation files updated", metrics.doc_files)); + } + + for change in changes + .iter() + .filter(|change| change.additions + change.deletions > 400) + { + highlights.push(format!( + "Large change in {} ({:+} / {:-})", + change.primary_path(), + change.additions, + change.deletions + )); + } + + highlights +} + +fn analyze_findings( + diff: &str, + changes: &[FileChange], + metrics: &DiffMetrics, +) -> Vec { + let mut findings = Vec::new(); + if !metrics.has_tests() && metrics.code_files > 0 { + findings.push( + ReviewFinding::new( + ReviewSeverity::Medium, + "Code changes detected without accompanying tests.", + ) + .with_location("Consider adding unit or integration coverage."), + ); + } + + let mut risky_locations: Vec = Vec::new(); + for change in changes.iter().filter(|change| change.is_code()) { + if change.additions > 0 && change.deletions == 0 && change.additions > 200 { + findings.push( + ReviewFinding::new( + ReviewSeverity::Low, + format!("Large addition in {}", change.primary_path()), + ) + .with_location(format!("{} lines added", change.additions)), + ); + } + } + + for line in diff.lines() { + if line.starts_with('+') { + if line.contains("unwrap(") || line.contains(".expect(") { + risky_locations.push(line.trim_start_matches('+').trim().to_string()); + } else if line.contains("unsafe ") { + findings.push( + ReviewFinding::new( + ReviewSeverity::High, + "Usage of `unsafe` detected; ensure invariants are documented.", + ) + .with_location(line.trim()), + ); + } else if line.contains("todo!") || line.contains("unimplemented!") { + findings.push( + ReviewFinding::new( + ReviewSeverity::Medium, + "TODO/unimplemented marker introduced.", + ) + .with_location(line.trim()), + ); + } + } + } + + if !risky_locations.is_empty() { + findings.push(ReviewFinding { + severity: ReviewSeverity::Low, + message: "New unwrap()/expect() calls introduced; confirm they are infallible." + .to_string(), + locations: risky_locations, + }); + } + + findings +} + +fn build_review_checklist( + metrics: &DiffMetrics, + changes: &[FileChange], +) -> Vec { + let mut checklist = Vec::new(); + checklist.push(ReviewChecklistItem::new( + "Tests cover the change surface", + metrics.has_tests(), + )); + checklist.push(ReviewChecklistItem::new( + "Documentation updated if behaviour changed", + metrics.has_docs(), + )); + + let includes_release_artifacts = changes.iter().any(|change| { + let path = change.primary_path(); + path.ends_with("CHANGELOG.md") || path.contains("release") + }); + if !includes_release_artifacts { + checklist.push(ReviewChecklistItem::new( + "Changelog or release notes updated if required", + false, + )); + } + + checklist +} + +// ------------------------------------------------------------------------------------------------- +// Git helpers +// ------------------------------------------------------------------------------------------------- + +fn discover_repo_root(path: &Path) -> Result { + let output = Command::new("git") + .arg("rev-parse") + .arg("--show-toplevel") + .current_dir(path) + .output()?; + if !output.status.success() { + return Err(Error::InvalidInput( + "The current directory is not inside a git repository.".to_string(), + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(PathBuf::from(stdout.trim())) +} + +fn capture_diff(root: &Path, mode: DiffCaptureMode<'_>) -> Result { + let mut cmd = Command::new("git"); + cmd.current_dir(root); + match mode { + DiffCaptureMode::Staged => { + cmd.args(["diff", "--cached", "--unified=3", "--no-color"]); + } + DiffCaptureMode::WorkingTree => { + cmd.args(["diff", "--unified=3", "--no-color"]); + } + DiffCaptureMode::Range { base, head } => { + cmd.args([ + "diff", + "--unified=3", + "--no-color", + &format!("{base}..{head}"), + ]); + } + } + let output = cmd.output()?; + if !output.status.success() { + return Err(Error::Unknown(format!( + "git diff exited with status {}", + output.status + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn capture_range_diff(root: &Path, base: &str, head: &str) -> Result { + let output = Command::new("git") + .args([ + "diff", + "--unified=3", + "--no-color", + &format!("{base}..{head}"), + ]) + .current_dir(root) + .output()?; + if !output.status.success() { + return Err(Error::Unknown(format!( + "git diff exited with status {}", + output.status + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn resolve_merge_base(root: &Path, base: &str, head: &str) -> Result { + let output = Command::new("git") + .args(["merge-base", base, head]) + .current_dir(root) + .output()?; + if !output.status.success() { + return Err(Error::Unknown(format!( + "git merge-base exited with status {}", + output.status + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +// ------------------------------------------------------------------------------------------------- +// Utility helpers +// ------------------------------------------------------------------------------------------------- + +fn strip_path_prefix(value: &str, prefix: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_start_matches(prefix) + .trim_start_matches("./") + .to_string() +} + +fn count_symbol(diff: &str, symbol: char) -> usize { + diff.lines() + .filter(|line| { + line.starts_with(symbol) + && !matches!( + (symbol, line.chars().nth(1), line.chars().nth(2)), + ('+', Some('+'), Some('+')) | ('-', Some('-'), Some('-')) + ) + }) + .count() +} + +fn count_files(diff: &str) -> usize { + diff.lines() + .filter(|line| line.starts_with("diff --git")) + .count() +} + +static FILE_TEST_HINTS: [&str; 5] = ["tests/", "_test.", "test/", "spec/", "fixtures/"]; +static DOC_EXTENSIONS: [&str; 6] = [".md", ".rst", ".adoc", ".txt", ".mdx", ".markdown"]; +static CONFIG_EXTENSIONS: [&str; 6] = [".toml", ".yaml", ".yml", ".json", ".ini", ".conf"]; + +// ------------------------------------------------------------------------------------------------- +// Tests +// ------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_DIFF: &str = r#"diff --git a/src/foo.rs b/src/foo.rs +index e69de29..4b825dc 100644 +--- a/src/foo.rs ++++ b/src/foo.rs +@@ ++pub fn add(left: i32, right: i32) -> i32 { ++ left + right ++} +diff --git a/tests/foo_test.rs b/tests/foo_test.rs +new file mode 100644 +index 0000000..bf3b82c +--- /dev/null ++++ b/tests/foo_test.rs +@@ ++#[test] ++fn add_adds_numbers() { ++ assert_eq!(crate::add(2, 2), 4); ++} +diff --git a/README.md b/README.md +index 4b825dc..c8f2615 100644 +--- a/README.md ++++ b/README.md +@@ +-# Owlen ++# Owlen ++Updated docs +"#; + + #[test] + fn commit_template_infers_prefix_and_sections() { + let template = CommitTemplate::from_diff(SAMPLE_DIFF); + assert_eq!(template.prefix, "feat:"); + assert_eq!(template.summary.len(), 3); + assert_eq!(template.sections.len(), 2); + let tests_section = template + .sections + .iter() + .find(|section| section.title == "Testing") + .expect("testing section"); + assert!(tests_section.lines.iter().any(|line| line.contains("[x]"))); + let markdown = template.render_markdown(); + assert!(markdown.contains("Summary:")); + assert!(markdown.contains("tests")); + } + + #[test] + fn review_highlights_tests_gap() { + let diff = r#"diff --git a/src/lib.rs b/src/lib.rs +index e69de29..4b825dc 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ ++pub fn risky() { ++ let value = std::env::var("MISSING").unwrap(); ++ println!("{}", value); ++} +"#; + let context = PullRequestContext { + title: "Test PR".to_string(), + body: None, + author: Some("demo".to_string()), + base_branch: "main".to_string(), + head_branch: "feature".to_string(), + additions: 3, + deletions: 0, + changed_files: 1, + html_url: None, + }; + let review = PullRequestReview::from_diff(context, diff); + assert!( + review + .findings + .iter() + .any(|finding| finding.severity == ReviewSeverity::Medium) + ); + assert!( + review + .findings + .iter() + .any(|finding| finding.message.contains("unwrap")) + ); + let markdown = review.render_markdown(); + assert!(markdown.contains("Summary")); + assert!(markdown.contains("Checklist")); + } +} diff --git a/crates/owlen-core/src/github.rs b/crates/owlen-core/src/github.rs new file mode 100644 index 0000000..19abc27 --- /dev/null +++ b/crates/owlen-core/src/github.rs @@ -0,0 +1,258 @@ +use crate::automation::repo::{PullRequestContext, summarize_diff}; +use crate::{Error, Result}; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue, USER_AGENT}; +use serde::{Deserialize, Serialize}; + +const DEFAULT_API_ENDPOINT: &str = "https://api.github.com"; +const USER_AGENT_VALUE: &str = "owlen/0.2"; + +/// Lightweight GitHub API client used for repository automation workflows. +pub struct GithubClient { + client: reqwest::Client, + base_url: String, + token: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GithubConfig { + pub token: Option, + pub api_endpoint: Option, +} + +impl GithubClient { + pub fn new(config: GithubConfig) -> Result { + let client = reqwest::Client::builder() + .user_agent(USER_AGENT_VALUE) + .build() + .map_err(|err| Error::Network(err.to_string()))?; + Ok(Self { + client, + base_url: config + .api_endpoint + .unwrap_or_else(|| DEFAULT_API_ENDPOINT.to_string()), + token: config.token, + }) + } + + /// Fetch a pull request, returning diff text along with contextual metadata. + pub async fn pull_request( + &self, + owner: &str, + repo: &str, + number: u64, + ) -> Result { + let pr = self.fetch_pull_request(owner, repo, number).await?; + let diff = self.fetch_diff(owner, repo, number).await?; + let files = self.fetch_files(owner, repo, number).await?; + let stats = summarize_diff(&diff); + let context = PullRequestContext { + title: pr + .title + .clone() + .unwrap_or_else(|| format!("PR #{}", pr.number)), + body: pr.body.clone(), + author: pr.user.map(|user| user.login), + base_branch: pr.base.ref_field, + head_branch: pr.head.ref_field, + additions: stats.additions as u64, + deletions: stats.deletions as u64, + changed_files: stats.files as u64, + html_url: pr.html_url, + }; + + Ok(PullRequestDetails { + context, + diff, + files, + }) + } + + async fn fetch_pull_request( + &self, + owner: &str, + repo: &str, + number: u64, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls/{}", + self.base_url.trim_end_matches('/'), + owner, + repo, + number + ); + let response = self + .request(&url, Some("application/vnd.github+json"))? + .send() + .await + .map_err(|err| Error::Network(err.to_string()))?; + if !response.status().is_success() { + return Err(Error::Network(format!( + "GitHub returned status {} while fetching pull request", + response.status() + ))); + } + response + .json::() + .await + .map_err(|err| Error::Network(err.to_string())) + } + + async fn fetch_diff(&self, owner: &str, repo: &str, number: u64) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls/{}", + self.base_url.trim_end_matches('/'), + owner, + repo, + number + ); + let response = self + .request(&url, Some("application/vnd.github.v3.diff"))? + .send() + .await + .map_err(|err| Error::Network(err.to_string()))?; + if !response.status().is_success() { + return Err(Error::Network(format!( + "GitHub returned status {} while downloading diff", + response.status() + ))); + } + response + .text() + .await + .map_err(|err| Error::Network(err.to_string())) + } + + async fn fetch_files( + &self, + owner: &str, + repo: &str, + number: u64, + ) -> Result> { + let mut results = Vec::new(); + let mut next_url = Some(format!( + "{}/repos/{}/{}/pulls/{}/files?per_page=100", + self.base_url.trim_end_matches('/'), + owner, + repo, + number + )); + + while let Some(url) = next_url { + let response = self + .request(&url, Some("application/vnd.github+json"))? + .send() + .await + .map_err(|err| Error::Network(err.to_string()))?; + if !response.status().is_success() { + return Err(Error::Network(format!( + "GitHub returned status {} while listing PR files", + response.status() + ))); + } + let link_header = response.headers().get("link").cloned(); + let page: Vec = response + .json() + .await + .map_err(|err| Error::Network(err.to_string()))?; + results.extend(page.into_iter().map(GithubPullFile::from)); + next_url = next_link(link_header.as_ref()); + } + + Ok(results) + } + + fn request(&self, url: &str, accept: Option<&str>) -> Result { + let mut builder = self.client.get(url); + builder = builder.header(USER_AGENT, USER_AGENT_VALUE); + if let Some(token) = &self.token { + builder = builder.header(AUTHORIZATION, format!("token {}", token)); + } + if let Some(accept) = accept { + builder = builder.header(ACCEPT, accept); + } + Ok(builder) + } +} + +/// Rich pull request details used by automation workflows. +#[derive(Debug, Clone)] +pub struct PullRequestDetails { + pub context: PullRequestContext, + pub diff: String, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GithubPullFile { + pub filename: String, + pub status: String, + pub additions: u64, + pub deletions: u64, + pub changes: u64, + pub patch: Option, +} + +impl From for GithubPullFile { + fn from(value: GitHubPullFileApi) -> Self { + Self { + filename: value.filename, + status: value.status, + additions: value.additions, + deletions: value.deletions, + changes: value.changes, + patch: value.patch, + } + } +} + +#[derive(Debug, Deserialize)] +struct GitHubPullRequest { + number: u64, + title: Option, + body: Option, + user: Option, + base: GitRef, + head: GitRef, + html_url: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubUser { + login: String, +} + +#[derive(Debug, Deserialize)] +struct GitRef { + #[serde(rename = "ref")] + ref_field: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubPullFileApi { + filename: String, + status: String, + additions: u64, + deletions: u64, + changes: u64, + #[serde(default)] + patch: Option, +} + +fn next_link(header: Option<&HeaderValue>) -> Option { + let header = header?.to_str().ok()?; + for part in header.split(',') { + let segments: Vec<&str> = part.split(';').collect(); + if segments.len() < 2 { + continue; + } + let url = segments[0] + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + let rel = segments[1].trim(); + if rel == "rel=\"next\"" { + return Some(url.to_string()); + } + } + None +} diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index be21a1a..c51c255 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod agent; pub mod agent_registry; +pub mod automation; pub mod config; pub mod consent; pub mod conversation; @@ -14,6 +15,7 @@ pub mod credentials; pub mod encryption; pub mod facade; pub mod formatting; +pub mod github; pub mod input; pub mod llm; pub mod mcp; @@ -37,12 +39,14 @@ pub mod wrap_cursor; pub use agent::*; pub use agent_registry::*; +pub use automation::*; pub use config::*; pub use consent::*; pub use conversation::*; pub use credentials::*; pub use encryption::*; pub use formatting::*; +pub use github::*; pub use input::*; pub use oauth::*; // Export MCP types but exclude test_utils to avoid ambiguity diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index f9d786f..b6faa13 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -9,6 +9,7 @@ use crossterm::{ use image::{self, GenericImageView, imageops::FilterType}; use mime_guess; use owlen_core::Error as CoreError; +use owlen_core::automation::repo::{DiffCaptureMode, RepoAutomation}; use owlen_core::consent::ConsentScope; use owlen_core::facade::llm_client::LlmClient; use owlen_core::mcp::presets::{self, PresetTier}; @@ -3832,6 +3833,47 @@ impl ChatApp { parts.join(" · ") } + async fn repo_commit_template_markdown(&self, working_tree: bool) -> Result { + let repo_root = self.file_tree.root().to_path_buf(); + let mode = if working_tree { + DiffCaptureMode::WorkingTree + } else { + DiffCaptureMode::Staged + }; + let markdown = task::spawn_blocking(move || -> Result { + let automation = + RepoAutomation::from_path(&repo_root).map_err(|err| anyhow!(err.to_string()))?; + let template = automation + .generate_commit_template(mode) + .map_err(|err| anyhow!(err.to_string()))?; + Ok(template.render_markdown()) + }) + .await + .map_err(|err| anyhow!(err.to_string()))??; + Ok(markdown) + } + + async fn repo_review_markdown( + &self, + base: Option, + head: Option, + ) -> Result { + let repo_root = self.file_tree.root().to_path_buf(); + let base_clone = base.clone(); + let head_clone = head.clone(); + let markdown = task::spawn_blocking(move || -> Result { + let automation = + RepoAutomation::from_path(&repo_root).map_err(|err| anyhow!(err.to_string()))?; + let review = automation + .generate_pr_review(base_clone.as_deref(), head_clone.as_deref()) + .map_err(|err| anyhow!(err.to_string()))?; + Ok(review.render_markdown()) + }) + .await + .map_err(|err| anyhow!(err.to_string()))??; + Ok(markdown) + } + fn format_attachment_size(bytes: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; let mut value = bytes as f64; @@ -8455,6 +8497,142 @@ impl ChatApp { } } } + "repo" => { + if args.is_empty() { + self.error = + Some("Usage: :repo ".to_string()); + self.status = + "Specify :repo template or :repo review".to_string(); + } else { + match args[0].to_ascii_lowercase().as_str() { + "template" => { + let working = args[1..].iter().any(|flag| { + matches!( + flag.to_ascii_lowercase().as_str(), + "--working" | "--unstaged" | "--all" + ) + }); + match self + .repo_commit_template_markdown(working) + .await + { + Ok(markdown) => { + self.controller + .conversation_mut() + .push_system_message(format!( + "💡 Commit template suggestion\n\n{}", + markdown + )); + self.notify_new_activity(); + self.status = + "Commit template generated".to_string(); + self.error = None; + } + Err(err) => { + self.error = Some(format!( + "Failed to build commit template: {}", + err + )); + self.status = + "Commit template generation failed" + .to_string(); + } + } + } + "review" => { + let mut idx = 1; + let mut base: Option = None; + let mut head: Option = None; + let mut parse_error: Option = None; + while idx < args.len() { + match args[idx] { + "--base" => { + if let Some(value) = args.get(idx + 1) { + base = Some(value.to_string()); + idx += 2; + } else { + parse_error = Some( + "--base requires a branch name" + .to_string(), + ); + break; + } + } + "--head" => { + if let Some(value) = args.get(idx + 1) { + head = Some(value.to_string()); + idx += 2; + } else { + parse_error = Some( + "--head requires a ref" + .to_string(), + ); + break; + } + } + other => { + parse_error = Some(format!( + "Unknown repo review option '{}'.", + other + )); + break; + } + } + } + + if let Some(message) = parse_error { + self.error = Some(message); + self.status = "Usage: :repo review [--base BRANCH] [--head REF]".to_string(); + } else { + match self + .repo_review_markdown( + base.clone(), + head.clone(), + ) + .await + { + Ok(markdown) => { + self.controller + .conversation_mut() + .push_system_message(format!( + "🧾 Repo review summary\n\n{}", + markdown + )); + self.notify_new_activity(); + self.status = format!( + "Review generated for {} ← {}", + head.as_deref().unwrap_or("HEAD"), + base.as_deref() + .unwrap_or("origin/main"), + ); + self.error = None; + } + Err(err) => { + self.error = Some(format!( + "Repo review failed: {}", + err + )); + self.status = + "Unable to generate review" + .to_string(); + } + } + } + } + other => { + self.error = Some(format!( + "Unknown repo command '{}'. Use :repo template or :repo review.", + other + )); + self.status = + "Usage: :repo ".to_string(); + } + } + } + self.set_input_mode(InputMode::Normal); + self.command_palette.clear(); + return Ok(AppState::Running); + } "files" | "explorer" => { if !self.is_code_mode() { self.status = diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 8525fac..f4fc51a 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -135,6 +135,15 @@ const PREVIEW_PROVIDER: CommandPreview = CommandPreview { ], }; +const PREVIEW_REPO: CommandPreview = CommandPreview { + title: "Repo automation", + body: &[ + "Generate commit templates or code reviews from the current workspace.", + "Usage: :repo template [--working]", + "Usage: :repo review [--base BRANCH] [--head REF]", + ], +}; + const COMMANDS: &[CommandDescriptor] = &[ CommandDescriptor { keywords: &["quit"], @@ -316,6 +325,24 @@ const COMMANDS: &[CommandDescriptor] = &[ keybinding: None, preview: None, }, + CommandDescriptor { + keywords: &["repo template"], + description: "Generate a conventional commit template from staged changes", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["repo", "automation", "commit"], + keybinding: None, + preview: Some(&PREVIEW_REPO), + }, + CommandDescriptor { + keywords: &["repo review"], + description: "Summarise the current branch as a pull request review", + category: CommandCategory::Tools, + modes: &["Command"], + tags: &["repo", "automation", "review"], + keybinding: None, + preview: Some(&PREVIEW_REPO), + }, CommandDescriptor { keywords: &["help", "h"], description: "Open the help overlay", diff --git a/docs/configuration.md b/docs/configuration.md index c890328..166881a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -198,6 +198,15 @@ list_ttl_secs = 60 default_context_window = 8192 ``` +## Repository Automation (GitHub) + +The `owlen repo` CLI commands and the matching `:repo` TUI shortcuts can fetch pull requests directly from GitHub. Authentication is driven primarily through environment variables: + +- Set `GITHUB_TOKEN` to a personal access token with at least `repo:read` scope. The CLI reads this variable automatically; override it with `--token` or pick a different environment variable with `--token-env NAME`. +- GitHub Enterprise instances are supported via the `--api-endpoint` flag (for example, `https://github.my-company.com/api/v3`). The same flag is respected when you run `owlen repo review` or when the TUI invokes the automation helpers. + +When no token is available, the commands fall back to analysing local diffs only. + Key points to keep in mind: - **Base URL normalisation** – Owlen accepts `https://ollama.com`, `https://ollama.com/api`, `https://ollama.com/v1`, and the legacy `https://api.ollama.com`, canonicalising them to the correct HTTPS host. Local deployments get the same treatment for `http://localhost:11434`, `/api`, or `/v1`. You only need to customise `base_url` when the service is proxied elsewhere. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d1594a1..01f0a21 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -126,6 +126,12 @@ ollama list This workaround mirrors what `ollama signin` should do—register the key pair with Ollama Cloud—without waiting for the patched release. Once you upgrade to v0.12.4 or newer, the interactive sign-in command works again. +## Repository Automation Issues + +- **401 / authentication errors**: `owlen repo review` needs a GitHub token. Export `GITHUB_TOKEN=` (with at least `repo:read`) before running the command or use `--token `. In the TUI, the same environment variable is read when `:repo review` is executed. +- **404 responses**: Verify that the `--owner` and `--repo` flags match the full name of the repository (`owner/repo`). A 404 from GitHub for private projects usually means the token lacks access. +- **Enterprise endpoints**: Supply `--api-endpoint https://github.example.com/api/v3` so the client uses your self-hosted instance instead of `api.github.com`. + ## Performance Tuning If you are experiencing performance issues, you can try the following: