use clap::Subcommand;
use edda_bridge_claude::issue_proposal::{self, IssueProposal, ProposalSource, ProposalStatus};
use std::path::Path;
#[derive(Subcommand)]
pub enum ProposeCmd {
Create {
#[arg(long)]
title: String,
#[arg(long)]
body: String,
#[arg(long = "label")]
labels: Vec<String>,
#[arg(long, default_value = "manual")]
source: String,
#[arg(long)]
source_ref: Option<String>,
},
FromScan {
scan_id: String,
index: usize,
},
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
json: bool,
},
Show {
prop_id: String,
},
Approve {
prop_id: String,
#[arg(long, default_value = "human")]
by: String,
#[arg(long)]
dry_run: bool,
},
Dismiss {
prop_id: String,
#[arg(long)]
reason: Option<String>,
},
}
pub fn execute(cmd: ProposeCmd, repo_root: &Path) -> anyhow::Result<()> {
let project_id = edda_store::project_id(repo_root);
match cmd {
ProposeCmd::Create {
title,
body,
labels,
source,
source_ref,
} => execute_create(
&project_id,
&title,
&body,
&labels,
&source,
source_ref.as_deref(),
),
ProposeCmd::FromScan { scan_id, index } => execute_from_scan(&project_id, &scan_id, index),
ProposeCmd::List { status, json } => execute_list(&project_id, status.as_deref(), json),
ProposeCmd::Show { prop_id } => execute_show(&project_id, &prop_id),
ProposeCmd::Approve {
prop_id,
by,
dry_run,
} => execute_approve(&project_id, &prop_id, &by, dry_run, repo_root),
ProposeCmd::Dismiss { prop_id, reason } => {
execute_dismiss(&project_id, &prop_id, reason.as_deref())
}
}
}
fn parse_source(s: &str) -> anyhow::Result<ProposalSource> {
match s {
"scan" => Ok(ProposalSource::Scan),
"postmortem" => Ok(ProposalSource::Postmortem),
"manual" => Ok(ProposalSource::Manual),
"bridge" => Ok(ProposalSource::Bridge),
_ => anyhow::bail!("Unknown source: {s} (expected: scan, postmortem, manual, bridge)"),
}
}
fn execute_create(
project_id: &str,
title: &str,
body: &str,
labels: &[String],
source: &str,
source_ref: Option<&str>,
) -> anyhow::Result<()> {
let source = parse_source(source)?;
let proposal = IssueProposal {
proposal_id: issue_proposal::new_proposal_id(),
created_at: now_rfc3339(),
source,
source_ref: source_ref.map(String::from),
title: title.to_string(),
body: body.to_string(),
labels: labels.to_vec(),
status: ProposalStatus::Pending,
approved_by: None,
approved_at: None,
issue_url: None,
issue_number: None,
dismiss_reason: None,
};
issue_proposal::save_proposal(project_id, &proposal)?;
println!("Proposal created: {}", proposal.proposal_id);
println!(" Title: {}", proposal.title);
println!();
println!(
"Use `edda propose-issue approve {}` to create the GitHub issue.",
proposal.proposal_id
);
Ok(())
}
fn execute_from_scan(project_id: &str, scan_id: &str, index: usize) -> anyhow::Result<()> {
let proposal = issue_proposal::create_proposal_from_scan_gap(project_id, scan_id, index)?;
println!("Proposal created from scan gap: {}", proposal.proposal_id);
println!(" Title: {}", proposal.title);
println!(" Source: {}:{}", scan_id, index);
println!();
println!(
"Use `edda propose-issue approve {}` to create the GitHub issue.",
proposal.proposal_id
);
Ok(())
}
fn execute_list(project_id: &str, status: Option<&str>, json: bool) -> anyhow::Result<()> {
let status_filter = match status {
Some("pending") => Some(ProposalStatus::Pending),
Some("approved") => Some(ProposalStatus::Approved),
Some("dismissed") => Some(ProposalStatus::Dismissed),
Some(s) => {
anyhow::bail!("Unknown status filter: {s} (expected: pending, approved, dismissed)")
}
None => None,
};
let proposals = issue_proposal::list_proposals(project_id, status_filter.as_ref())?;
if json {
println!("{}", serde_json::to_string_pretty(&proposals)?);
return Ok(());
}
if proposals.is_empty() {
let qualifier = status.unwrap_or("any");
println!("No {qualifier} issue proposals found.");
println!("Use `edda propose-issue create --title \"...\" --body \"...\"` to create one.");
return Ok(());
}
for p in &proposals {
let status_str = match p.status {
ProposalStatus::Pending => "PENDING",
ProposalStatus::Approved => "approved",
ProposalStatus::Dismissed => "dismissed",
};
let labels_str = if p.labels.is_empty() {
String::new()
} else {
format!(" [{}]", p.labels.join(", "))
};
let issue_str = p
.issue_url
.as_deref()
.map(|u| format!(" -> {u}"))
.unwrap_or_default();
println!(
" {} [{}] {}{}{} ({})",
p.proposal_id, status_str, p.title, labels_str, issue_str, p.created_at
);
}
Ok(())
}
fn execute_show(project_id: &str, prop_id: &str) -> anyhow::Result<()> {
let p = issue_proposal::load_proposal(project_id, prop_id)?;
println!("Proposal: {}", p.proposal_id);
println!("Status: {:?}", p.status);
println!("Created: {}", p.created_at);
println!("Source: {:?}", p.source);
if let Some(ref sr) = p.source_ref {
println!("Source ref: {sr}");
}
println!("Title: {}", p.title);
if !p.labels.is_empty() {
println!("Labels: {}", p.labels.join(", "));
}
println!();
println!("Body:");
println!("{}", p.body);
if let Some(ref by) = p.approved_by {
println!();
println!("Approved by: {by}");
if let Some(ref at) = p.approved_at {
println!("Approved at: {at}");
}
}
if let Some(ref url) = p.issue_url {
println!("Issue URL: {url}");
}
if let Some(num) = p.issue_number {
println!("Issue #: {num}");
}
if let Some(ref reason) = p.dismiss_reason {
println!();
println!("Dismiss reason: {reason}");
}
Ok(())
}
fn execute_approve(
project_id: &str,
prop_id: &str,
by: &str,
dry_run: bool,
repo_root: &Path,
) -> anyhow::Result<()> {
check_rbac_if_configured(repo_root, by, "approve_issue_proposal")?;
let proposal = if dry_run {
issue_proposal::load_proposal(project_id, prop_id)?
} else {
issue_proposal::approve_proposal(project_id, prop_id, by)?
};
if dry_run {
println!("=== DRY RUN ===");
println!("Would create GitHub issue:");
println!(" Title: {}", proposal.title);
println!(" Labels: {}", proposal.labels.join(", "));
println!();
println!("Body:");
println!("{}", proposal.body);
return Ok(());
}
let cwd = repo_root.to_string_lossy().to_string();
match create_github_issue(&proposal, &cwd) {
Ok((url, number)) => {
issue_proposal::record_issue_created(project_id, prop_id, &url, number)?;
println!("Issue created: {url}");
}
Err(e) => {
eprintln!("Warning: Proposal approved but issue creation failed: {e}");
eprintln!("The proposal is marked as approved. Create the issue manually:");
eprintln!(
" gh issue create --title {:?} --body {:?}",
proposal.title, proposal.body
);
}
}
Ok(())
}
fn execute_dismiss(project_id: &str, prop_id: &str, reason: Option<&str>) -> anyhow::Result<()> {
issue_proposal::dismiss_proposal(project_id, prop_id, reason)?;
println!("Proposal {prop_id} dismissed.");
if let Some(r) = reason {
println!(" Reason: {r}");
}
Ok(())
}
fn create_github_issue(proposal: &IssueProposal, cwd: &str) -> anyhow::Result<(String, u64)> {
let mut args = vec![
"issue".to_string(),
"create".to_string(),
"--title".to_string(),
proposal.title.clone(),
"--body".to_string(),
proposal.body.clone(),
];
for label in &proposal.labels {
args.push("--label".to_string());
args.push(label.clone());
}
let output = std::process::Command::new("gh")
.args(&args)
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gh issue create failed: {stderr}");
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let number = url
.rsplit('/')
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
Ok((url, number))
}
fn check_rbac_if_configured(repo_root: &Path, actor: &str, action: &str) -> anyhow::Result<()> {
let edda_dir = repo_root.join(".edda");
let policy = edda_core::policy::load_policy_from_dir(&edda_dir)?;
if policy.permissions.is_none() {
return Ok(());
}
let actors = edda_core::policy::load_actors_from_dir(&edda_dir)?;
let req = edda_core::policy::AuthzRequest {
actor: actor.to_string(),
action: action.to_string(),
resource: None,
};
let result = edda_core::policy::evaluate_authz(&req, &policy, &actors);
if !result.allowed {
let reason = result.reason.unwrap_or_default();
anyhow::bail!("RBAC denied: actor '{actor}' lacks '{action}' permission. {reason}");
}
Ok(())
}
fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_source_valid() {
assert_eq!(parse_source("scan").unwrap(), ProposalSource::Scan);
assert_eq!(
parse_source("postmortem").unwrap(),
ProposalSource::Postmortem
);
assert_eq!(parse_source("manual").unwrap(), ProposalSource::Manual);
assert_eq!(parse_source("bridge").unwrap(), ProposalSource::Bridge);
}
#[test]
fn parse_source_invalid() {
assert!(parse_source("unknown").is_err());
}
}