feat: add repo automation workflows

This commit is contained in:
2025-10-26 05:49:21 +01:00
parent 28b6eb0a9a
commit 7aa80fb0a4
13 changed files with 1654 additions and 1 deletions

View File

@@ -2,5 +2,6 @@
pub mod cloud;
pub mod providers;
pub mod repo;
pub mod security;
pub mod tools;

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
/// Base ref for local diff review (default: origin/main).
#[arg(long)]
pub base: Option<String>,
/// Head ref for local diff review (default: HEAD).
#[arg(long)]
pub head: Option<String>,
/// Owner of the GitHub repository.
#[arg(long)]
pub owner: Option<String>,
/// Repository name on GitHub.
#[arg(long = "repo")]
pub repository: Option<String>,
/// Pull request number to fetch from GitHub.
#[arg(long)]
pub number: Option<u64>,
/// GitHub personal access token (falls back to environment variable).
#[arg(long)]
pub token: Option<String>,
/// 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<String>,
/// Path to a diff file to analyse instead of hitting Git or GitHub.
#[arg(long, value_name = "FILE")]
pub diff_file: Option<PathBuf>,
/// 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),
},
}
}

View File

@@ -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"

View File

@@ -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,
};

View File

@@ -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<Path>) -> Result<Self> {
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<CommitTemplate> {
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<PullRequestReview> {
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<String>,
pub sections: Vec<CommitTemplateSection>,
pub workflow: Vec<WorkflowStep>,
}
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(&sections)),
];
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!("{} <describe change>\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 &section.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<String>,
}
/// Metadata about a pull request / change range.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequestContext {
pub title: String,
pub body: Option<String>,
pub author: Option<String>,
pub base_branch: String,
pub head_branch: String,
pub additions: u64,
pub deletions: u64,
pub changed_files: u64,
pub html_url: Option<String>,
}
/// Markdown-ready automation review artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequestReview {
pub context: PullRequestContext,
pub summary: String,
pub highlights: Vec<String>,
pub findings: Vec<ReviewFinding>,
pub checklist: Vec<ReviewChecklistItem>,
pub workflow: Vec<WorkflowStep>,
}
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<String>,
}
impl ReviewFinding {
fn new(severity: ReviewSeverity, message: impl Into<String>) -> Self {
Self {
severity,
message: message.into(),
locations: Vec::new(),
}
}
fn with_location(mut self, location: impl Into<String>) -> 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<String>, 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<String>, outcome: impl Into<String>) -> 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<FileChange> {
let mut changes = Vec::new();
let mut current: Option<FileChange> = 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<CommitTemplateSection> {
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| &section.lines)
.filter(|line| line.contains("tests"))
.cloned()
.collect::<Vec<_>>()
.join(", ")
}
fn build_highlights(changes: &[FileChange], metrics: &DiffMetrics) -> Vec<String> {
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<ReviewFinding> {
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<String> = 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<ReviewChecklistItem> {
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<PathBuf> {
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<String> {
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<String> {
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<String> {
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"));
}
}

View File

@@ -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<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GithubConfig {
pub token: Option<String>,
pub api_endpoint: Option<String>,
}
impl GithubClient {
pub fn new(config: GithubConfig) -> Result<Self> {
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<PullRequestDetails> {
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<GitHubPullRequest> {
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::<GitHubPullRequest>()
.await
.map_err(|err| Error::Network(err.to_string()))
}
async fn fetch_diff(&self, owner: &str, repo: &str, number: u64) -> Result<String> {
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<Vec<GithubPullFile>> {
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<GitHubPullFileApi> = 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<reqwest::RequestBuilder> {
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<GithubPullFile>,
}
#[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<String>,
}
impl From<GitHubPullFileApi> 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<String>,
body: Option<String>,
user: Option<GitHubUser>,
base: GitRef,
head: GitRef,
html_url: Option<String>,
}
#[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<String>,
}
fn next_link(header: Option<&HeaderValue>) -> Option<String> {
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
}

View File

@@ -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

View File

@@ -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<String> {
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<String> {
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<String>,
head: Option<String>,
) -> Result<String> {
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<String> {
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 <template|review>".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<String> = None;
let mut head: Option<String> = None;
let mut parse_error: Option<String> = 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 <template|review>".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 =

View File

@@ -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",