src/lib.rs
Ref: Size: 28.3 KiB
pub mod cache;
pub mod cli;
pub mod commit_link;
pub mod dag;
pub mod editor;
pub mod error;
pub mod event;
pub mod identity;
pub mod issue;
pub mod log;
pub mod patch;
pub mod state;
pub mod signing;
pub mod status;
pub mod sync;
pub mod sync_lock;
pub mod trust;
pub mod tui;
use base64::Engine;
use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;
/// Check if the reviewer's base ref has moved ahead of the patch's latest revision.
pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> {
let latest_commit = &patch.revisions.last()?.commit;
let commit_oid = git2::Oid::from_str(latest_commit).ok()?;
let base_ref = format!("refs/heads/{}", patch.base_ref);
let base_tip = repo.refname_to_id(&base_ref).ok()?;
let merge_base = repo.merge_base(commit_oid, base_tip).ok()?;
if merge_base == base_tip {
return None;
}
let (ahead, _) = repo.graph_ahead_behind(base_tip, merge_base).ok()?;
if ahead == 0 {
return None;
}
let mb_short = &merge_base.to_string()[..8];
Some(format!(
"\u{26a0} Based on {}@{} ({} commits behind your {})",
patch.base_ref, mb_short, ahead, patch.base_ref
))
}
fn maybe_auto_sync(repo: &Repository) {
let enabled = repo
.config()
.ok()
.and_then(|c| c.get_bool("collab.autoSync").ok())
.unwrap_or(true);
if !enabled {
return;
}
let remote = repo
.config()
.ok()
.and_then(|c| c.get_string("collab.autoSyncRemote").ok())
.unwrap_or_else(|| "origin".to_string());
eprintln!("Auto-syncing with '{}'...", remote);
match sync::sync(repo, &remote) {
Ok(()) => {}
Err(error::Error::PartialSync { succeeded, total }) => {
eprintln!("warning: auto-sync partially failed ({}/{} refs pushed)", succeeded, total);
}
Err(e) => {
eprintln!("warning: auto-sync failed: {}", e);
}
}
}
pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
let is_write = cli.command.is_write();
match cli.command {
Commands::Init => sync::init(repo),
Commands::Issue(cmd) => match cmd {
IssueCmd::Open { title, body, relates_to } => {
let id = issue::open(repo, &title, &body, relates_to.as_deref())?;
println!("Opened issue {:.8}", id);
Ok(())
}
IssueCmd::List { all, archived, limit, offset, json, sort } => {
if json {
let output = issue::list_json(repo, all, archived, sort)?;
println!("{}", output);
return Ok(());
}
let entries = issue::list(repo, all, archived, limit, offset, sort)?;
if entries.is_empty() {
println!("No issues found.");
} else {
for e in &entries {
let i = &e.issue;
let status = i.status.as_str();
let labels = if i.labels.is_empty() {
String::new()
} else {
format!(" [{}]", i.labels.join(", "))
};
let unread = match e.unread {
Some(n) if n > 0 => format!(" ({} new)", n),
_ => String::new(),
};
println!(
"{:.8} {:6} {}{} (by {}){}",
i.id, status, i.title, labels, i.author.name, unread
);
}
}
Ok(())
}
IssueCmd::Show { id, json } => {
if json {
let output = issue::show_json(repo, &id)?;
println!("{}", output);
return Ok(());
}
let i = issue::show(repo, &id)?;
println!("Issue {} [{}]", &i.id[..8], i.status);
println!("Title: {}", i.title);
println!("Author: {} <{}>", i.author.name, i.author.email);
println!("Created: {}", i.created_at);
if !i.labels.is_empty() {
println!("Labels: {}", i.labels.join(", "));
}
if !i.assignees.is_empty() {
println!("Assignees: {}", i.assignees.join(", "));
}
if let Some(ref relates_to) = i.relates_to {
println!("Relates-to: {:.8}", relates_to);
}
if let Some(ref reason) = i.close_reason {
println!("Closed: {}", reason);
}
if !i.body.is_empty() {
println!("\n{}", i.body);
}
if !i.comments.is_empty() {
println!("\n--- Comments ---");
for c in &i.comments {
println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
}
}
if !i.linked_commits.is_empty() {
println!("\n--- Linked Commits ---");
for lc in &i.linked_commits {
let short_sha = if lc.commit.len() >= 7 { &lc.commit[..7] } else { &lc.commit };
let (subject, commit_author) = match git2::Oid::from_str(&lc.commit)
.ok()
.and_then(|oid| repo.find_commit(oid).ok())
{
Some(commit) => {
let subject = commit
.summary()
.map(|s| truncate_summary(s, 60))
.unwrap_or_default();
let author = commit
.author()
.name()
.unwrap_or("unknown")
.to_string();
(Some(subject), Some(author))
}
None => (None, None),
};
match (subject, commit_author) {
(Some(subject), Some(author)) => {
println!(
"· linked {} \"{}\" by {} (linked by {}, {})",
short_sha,
subject,
author,
lc.event_author.name,
lc.event_timestamp,
);
}
_ => {
println!(
"· linked {} (commit {} not in local repo) (linked by {}, {})",
short_sha,
short_sha,
lc.event_author.name,
lc.event_timestamp,
);
}
}
}
}
Ok(())
}
IssueCmd::Label { id, label } => {
issue::label(repo, &id, &label)?;
println!("Label '{}' added.", label);
Ok(())
}
IssueCmd::Unlabel { id, label } => {
issue::unlabel(repo, &id, &label)?;
println!("Label '{}' removed.", label);
Ok(())
}
IssueCmd::Assign { id, name } => {
issue::assign(repo, &id, &name)?;
println!("Assigned to '{}'.", name);
Ok(())
}
IssueCmd::Unassign { id, name } => {
issue::unassign(repo, &id, &name)?;
println!("Unassigned '{}'.", name);
Ok(())
}
IssueCmd::Edit { id, title, body } => {
issue::edit(repo, &id, title.as_deref(), body.as_deref())?;
println!("Issue updated.");
Ok(())
}
IssueCmd::Comment { id, body } => {
issue::comment(repo, &id, &body)?;
println!("Comment added.");
Ok(())
}
IssueCmd::Close { id, reason } => {
issue::close(repo, &id, reason.as_deref())?;
println!("Issue closed.");
Ok(())
}
IssueCmd::Delete { id } => {
let full_id = issue::delete(repo, &id)?;
println!("Deleted issue {:.8}", full_id);
Ok(())
}
},
Commands::Patch(cmd) => match cmd {
PatchCmd::Create {
title,
body,
base,
branch,
fixes,
} => {
// Resolve branch name: --branch takes priority, then current branch
let branch_name = if let Some(b) = branch {
b
} else {
// Default to current branch
let head_ref = repo.head().map_err(|_| {
error::Error::Cmd("cannot determine current branch (detached HEAD?)".to_string())
})?;
if head_ref.is_branch() {
let name = head_ref.shorthand().ok_or_else(|| {
error::Error::Cmd("cannot determine branch name".to_string())
})?;
if name == base {
return Err(error::Error::Cmd(
"cannot create patch from base branch; switch to a feature branch first".to_string(),
));
}
name.to_string()
} else {
// Detached HEAD — auto-create branch
let oid = head_ref.target().ok_or_else(|| {
error::Error::Cmd("cannot determine HEAD OID".to_string())
})?;
let commit = repo.find_commit(oid)?;
let short_oid = &oid.to_string()[..8];
let auto_branch = format!("collab/patch/{}", short_oid);
repo.branch(&auto_branch, &commit, false)?;
auto_branch
}
};
let id = patch::create(repo, &title, &body, &base, &branch_name, fixes.as_deref())?;
println!("Created patch {:.8}", id);
Ok(())
}
PatchCmd::List { all, archived, limit, offset, json, sort } => {
if json {
let output = patch::list_json(repo, all, archived, sort)?;
println!("{}", output);
return Ok(());
}
let entries = patch::list(repo, all, archived, limit, offset, sort)?;
if entries.is_empty() {
println!("No patches found.");
} else {
for e in &entries {
let p = &e.patch;
let stale = match p.staleness(repo) {
Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
Ok(_) => String::new(),
Err(_) => String::new(),
};
let unread = match e.unread {
Some(n) if n > 0 => format!(" ({} new)", n),
_ => String::new(),
};
println!(
"{:.8} {:6} {} (by {}){}{}",
p.id, p.status, p.title, p.author.name, stale, unread
);
}
}
Ok(())
}
PatchCmd::Show { id, json, revision } => {
if json {
let output = patch::show_json(repo, &id)?;
println!("{}", output);
return Ok(());
}
let p = patch::show(repo, &id)?;
let rev_count = p.revisions.len();
let status_detail = match p.staleness(repo) {
Ok((_, behind)) if behind > 0 => {
format!(" (branch is {} commits behind {})", behind, p.base_ref)
}
_ => String::new(),
};
println!("Patch {} [{}{}] (r{})", &p.id[..8], p.status, status_detail, rev_count);
println!("Title: {}", p.title);
println!("Author: {} <{}>", p.author.name, p.author.email);
match p.resolve_head(repo) {
Ok(_) => {
println!("Branch: {} -> {}", p.branch, p.base_ref);
if let Ok((ahead, behind)) = p.staleness(repo) {
let freshness = if behind == 0 { "up-to-date" } else { "outdated" };
println!("Commits: {} ahead, {} behind ({})", ahead, behind, freshness);
}
}
Err(_) => {
println!("Branch: {} (not found)", p.branch);
}
}
println!("Created: {}", p.created_at);
if let Some(ref fixes) = p.fixes {
println!("Fixes: {:.8}", fixes);
}
// Staleness warning
if let Some(warning) = staleness_warning(repo, &p) {
eprintln!("{}", warning);
}
// Show revisions
if !p.revisions.is_empty() {
println!("\n--- Revisions ---");
for rev in &p.revisions {
let short = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit };
let body_display = rev.body.as_deref().map(|b| format!(" {}", b)).unwrap_or_default();
println!(" r{}: {} ({}){}", rev.number, short, rev.timestamp, body_display);
}
}
if !p.body.is_empty() {
println!("\n{}", p.body);
}
// Filter reviews by revision if requested
let reviews: Vec<_> = if let Some(rev) = revision {
p.reviews.iter().filter(|r| r.revision == Some(rev)).collect()
} else {
p.reviews.iter().collect()
};
if !reviews.is_empty() {
println!("\n--- Reviews ---");
for r in &reviews {
let rev_label = r.revision.map(|n| format!(" (r{})", n)).unwrap_or_default();
println!(
"\n{} ({}) - {}{}:\n{}",
r.author.name, r.verdict, r.timestamp, rev_label, r.body
);
}
}
// Filter inline comments by revision if requested
let inline_comments: Vec<_> = if let Some(rev) = revision {
p.inline_comments.iter().filter(|c| c.revision == Some(rev)).collect()
} else {
p.inline_comments.iter().collect()
};
if !inline_comments.is_empty() {
println!("\n--- Inline Comments ---");
for c in &inline_comments {
let rev_label = c.revision.map(|n| format!(" r{}", n)).unwrap_or_default();
println!(
"\n{} on {}:{} ({}{}):\n {}",
c.author.name, c.file, c.line, c.timestamp, rev_label, c.body
);
}
}
// Thread comments always shown
if !p.comments.is_empty() {
println!("\n--- Comments ---");
for c in &p.comments {
println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
}
}
Ok(())
}
PatchCmd::Diff { id, revision, between } => {
if revision.is_some() && between.is_some() {
return Err(error::Error::Cmd(
"--revision and --between are mutually exclusive".to_string(),
));
}
let between_pair = between.map(|v| {
let from = v[0];
let to = v.get(1).copied();
(from, to)
});
let diff = patch::diff(repo, &id, revision, between_pair)?;
if diff.is_empty() {
println!("No diff available (commits may be identical).");
} else {
print!("{}", diff);
}
Ok(())
}
PatchCmd::Comment {
id,
body,
file,
line,
revision,
} => {
patch::comment(repo, &id, &body, file.as_deref(), line, revision)?;
println!("Comment added.");
Ok(())
}
PatchCmd::Review { id, verdict, body, revision } => {
let v: ReviewVerdict = verdict.parse().map_err(|_| {
git2::Error::from_str(
"verdict must be: approve, request-changes, comment, or reject",
)
})?;
patch::review(repo, &id, v, &body, revision)?;
println!("Review submitted.");
Ok(())
}
PatchCmd::Revise { id, body } => {
patch::revise(repo, &id, body.as_deref())?;
println!("Patch revised.");
Ok(())
}
PatchCmd::Log { id, json } => {
let p = patch::patch_log(repo, &id)?;
if json {
let output = patch::patch_log_json(&p)?;
println!("{}", output);
} else {
patch::patch_log_to_writer(repo, &p, &mut std::io::stdout())?;
}
Ok(())
}
PatchCmd::Close { id, reason } => {
patch::close(repo, &id, reason.as_deref())?;
println!("Patch closed.");
Ok(())
}
PatchCmd::Delete { id } => {
let full_id = patch::delete(repo, &id)?;
println!("Deleted patch {:.8}", full_id);
Ok(())
}
PatchCmd::Checkout { id } => {
patch::checkout(repo, &id)?;
Ok(())
}
},
Commands::Status => {
let project_status = status::compute(repo)?;
print!("{}", project_status);
Ok(())
}
Commands::Log { limit } => log::print_log(repo, limit),
Commands::Dashboard => tui::run(repo),
Commands::Completions { .. } => unreachable!("handled before repo open"),
Commands::Sync { remote } => sync::sync(repo, &remote),
Commands::InitKey { force } => {
let config_dir = signing::signing_key_dir()?;
let sk_path = config_dir.join("signing-key");
if sk_path.exists() && !force {
return Err(error::Error::Signing(
"signing key already exists; use --force to overwrite".to_string(),
));
}
let vk = signing::generate_keypair(&config_dir)?;
let pubkey_b64 =
base64::engine::general_purpose::STANDARD.encode(vk.to_bytes());
println!("Signing key generated.");
println!("Public key: {}", pubkey_b64);
Ok(())
}
Commands::Whoami => {
let info = identity::whoami(repo)?;
println!("{}", info);
Ok(())
}
Commands::Identity(cmd) => match cmd {
IdentityCmd::Alias { email } => {
let added = identity::add_alias(repo, &email)?;
if added {
println!("Alias '{}' added.", email);
} else {
println!("Alias '{}' already exists.", email);
}
Ok(())
}
IdentityCmd::Unalias { email } => {
identity::remove_alias(repo, &email)?;
println!("Alias '{}' removed.", email);
Ok(())
}
IdentityCmd::List => {
let author = identity::get_author(repo)?;
println!("Name: {}", author.name);
println!("Email: {} (primary)", author.email);
let aliases = identity::load_aliases(repo)?;
if aliases.is_empty() {
println!("Aliases: (none)");
} else {
println!("Aliases:");
for alias in &aliases {
println!(" {}", alias);
}
}
Ok(())
}
},
Commands::Key(cmd) => match cmd {
KeyCmd::Add {
pubkey,
self_key,
label,
global,
} => {
if self_key && pubkey.is_some() {
return Err(error::Error::Cmd(
"cannot specify both --self and a public key argument".to_string(),
));
}
let key = if self_key {
let config_dir = signing::signing_key_dir()?;
let vk = signing::load_verifying_key(&config_dir)?;
base64::engine::general_purpose::STANDARD.encode(vk.to_bytes())
} else {
match pubkey {
Some(k) => k,
None => {
return Err(error::Error::Cmd(
"public key argument required (or use --self)".to_string(),
));
}
}
};
trust::validate_pubkey(&key)?;
let added = if global {
trust::save_trusted_key_global(&key, label.as_deref())?
} else {
trust::save_trusted_key(repo, &key, label.as_deref())?
};
if added {
let label_display = label
.as_ref()
.map(|l| format!(" ({})", l))
.unwrap_or_default();
let scope = if global { " (global)" } else { "" };
println!("Trusted key added{}: {}{}", scope, key, label_display);
} else {
println!("Key {} is already trusted.", key);
}
Ok(())
}
KeyCmd::List { global } => {
if global {
let path = trust::global_trusted_keys_path()?;
let keys = trust::parse_trusted_keys_file(&path)?;
if keys.is_empty() {
println!("No global trusted keys configured.");
} else {
for k in &keys {
match &k.label {
Some(l) => println!("{} {}", k.pubkey, l),
None => println!("{}", k.pubkey),
}
}
}
} else {
let policy = trust::load_trust_policy(repo)?;
match policy {
trust::TrustPolicy::Unconfigured => {
println!("No trusted keys configured.");
}
trust::TrustPolicy::Configured(keys) => {
if keys.is_empty() {
println!("No trusted keys configured.");
} else {
for k in &keys {
match &k.label {
Some(l) => println!("{} {}", k.pubkey, l),
None => println!("{}", k.pubkey),
}
}
}
}
}
}
Ok(())
}
KeyCmd::Remove { pubkey, global } => {
let removed = if global {
trust::remove_trusted_key_global(&pubkey)?
} else {
trust::remove_trusted_key(repo, &pubkey)?
};
let label_display = removed
.label
.as_ref()
.map(|l| format!(" ({})", l))
.unwrap_or_default();
let scope = if global { " (global)" } else { "" };
println!("Removed trusted key{}: {}{}", scope, removed.pubkey, label_display);
Ok(())
}
},
Commands::Search { query } => search(repo, &query),
}?;
if is_write {
maybe_auto_sync(repo);
}
Ok(())
}
fn search(repo: &Repository, query: &str) -> Result<(), error::Error> {
let q = query.to_lowercase();
let issues = state::list_issues_with_archived(repo)?;
let mut issue_results: Vec<(&str, &str, &str, String)> = Vec::new();
for issue in &issues {
let mut matches = Vec::new();
if issue.title.to_lowercase().contains(&q) {
matches.push("title");
}
if issue.body.to_lowercase().contains(&q) {
matches.push("body");
}
if issue.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
matches.push("comment");
}
if !matches.is_empty() {
issue_results.push((
&issue.id,
issue.status.as_str(),
&issue.title,
matches.join(", "),
));
}
}
let patches = state::list_patches_with_archived(repo)?;
let mut patch_results: Vec<(&str, &str, &str, String)> = Vec::new();
for patch in &patches {
let mut matches = Vec::new();
if patch.title.to_lowercase().contains(&q) {
matches.push("title");
}
if patch.body.to_lowercase().contains(&q) {
matches.push("body");
}
if patch.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
matches.push("comment");
}
if patch.reviews.iter().any(|r| r.body.to_lowercase().contains(&q)) {
matches.push("review");
}
if patch.inline_comments.iter().any(|ic| ic.body.to_lowercase().contains(&q)) {
matches.push("inline comment");
}
if !matches.is_empty() {
patch_results.push((
&patch.id,
patch.status.as_str(),
&patch.title,
matches.join(", "),
));
}
}
println!("Issues:");
if issue_results.is_empty() {
println!(" (none)");
} else {
for (id, status, title, match_field) in &issue_results {
println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field);
}
}
println!();
println!("Patches:");
if patch_results.is_empty() {
println!(" (none)");
} else {
for (id, status, title, match_field) in &patch_results {
println!(" {:.8} {:6} {} ({} match)", id, status, title, match_field);
}
}
Ok(())
}
pub(crate) fn truncate_summary(s: &str, max_chars: usize) -> String {
let mut out = String::new();
for (count, c) in s.chars().enumerate() {
if count + 1 > max_chars {
out.push('…');
return out;
}
out.push(c);
}
out
}