feat: add repo automation workflows
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
|
||||
pub mod cloud;
|
||||
pub mod providers;
|
||||
pub mod repo;
|
||||
pub mod security;
|
||||
pub mod tools;
|
||||
|
||||
203
crates/owlen-cli/src/commands/repo.rs
Normal file
203
crates/owlen-cli/src/commands/repo.rs
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
9
crates/owlen-core/src/automation/mod.rs
Normal file
9
crates/owlen-core/src/automation/mod.rs
Normal 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,
|
||||
};
|
||||
943
crates/owlen-core/src/automation/repo.rs
Normal file
943
crates/owlen-core/src/automation/repo.rs
Normal 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(§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!("{} <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 §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<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| §ion.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"));
|
||||
}
|
||||
}
|
||||
258
crates/owlen-core/src/github.rs
Normal file
258
crates/owlen-core/src/github.rs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user