use clap::{Args, Subcommand};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::Read;
use std::path::PathBuf;
use homeboy::code_audit::FindingConfidence;
use homeboy::issues::{
apply_plan, build_findings_from_native_output, reconcile_scoped, GithubTracker,
IssueRenderContext, ReconcileConfig, ReconcileFindingsInput, ReconcilePlan, ReconcileResult,
Tracker,
};
use super::parse_key_val;
use super::CmdResult;
#[derive(Args)]
pub struct IssuesArgs {
#[command(subcommand)]
command: IssuesCommand,
}
#[derive(Subcommand)]
enum IssuesCommand {
Reconcile {
component_id: String,
#[arg(long, value_name = "URI")]
tracker: Option<String>,
#[arg(long, value_name = "PATH")]
findings: Option<String>,
#[arg(long = "from-output", value_name = "COMMAND=PATH", value_parser = parse_key_val)]
from_output: Vec<(String, String)>,
#[arg(long, value_name = "URL")]
run_url: Option<String>,
#[arg(long, default_value_t = true)]
suppress_from_config: bool,
#[arg(long, value_name = "CATEGORY")]
suppress_category: Vec<String>,
#[arg(long, value_name = "LABEL")]
suppress_label: Vec<String>,
#[arg(long, value_name = "CATEGORY")]
review_only_category: Vec<String>,
#[arg(long)]
no_refresh_closed: bool,
#[arg(long, default_value_t = 200)]
list_limit: usize,
#[arg(long)]
apply: bool,
#[arg(long, value_name = "PATH")]
path: Option<String>,
},
BuildFindings {
#[arg(long = "from-output", value_name = "COMMAND=PATH", value_parser = parse_key_val)]
from_output: Vec<(String, String)>,
#[arg(long, value_name = "URL")]
run_url: Option<String>,
},
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum IssuesCommandOutput {
Reconcile(ReconcileOutput),
BuildFindings(ReconcileFindingsInput),
}
#[derive(Serialize)]
pub struct ReconcileOutput {
pub component_id: String,
pub command: String,
pub applied: bool,
pub plan_summary: PlanSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<ReconcileResult>,
pub plan_lines: Vec<String>,
}
#[derive(Serialize, Default)]
pub struct PlanSummary {
pub total_actions: usize,
pub file_new: usize,
pub update: usize,
pub update_closed: usize,
pub close: usize,
pub close_duplicate: usize,
pub skip: usize,
}
pub fn run(args: IssuesArgs, _global: &super::GlobalArgs) -> CmdResult<IssuesCommandOutput> {
match args.command {
IssuesCommand::Reconcile {
component_id,
tracker: _tracker,
findings,
from_output,
run_url,
suppress_from_config,
suppress_category,
suppress_label,
review_only_category,
no_refresh_closed,
list_limit,
apply,
path,
} => {
let findings_input = read_reconcile_input(findings.as_deref(), &from_output, run_url)?;
let command_label = findings_input.command.clone();
let groups = into_issue_groups(findings_input, &component_id);
let config = build_reconcile_config(
&component_id,
path.as_deref(),
suppress_from_config,
suppress_category,
suppress_label,
review_only_category,
no_refresh_closed,
)?;
let tracker_impl = GithubTracker::new(component_id.clone()).with_path(path.clone());
let existing = tracker_impl.list_issues(&command_label, list_limit)?;
let plan = reconcile_scoped(&groups, &existing, &config, &command_label, &component_id);
let plan_lines = render_plan_lines(&plan);
let plan_summary = summarize_plan(&plan);
if apply {
let result = apply_plan(plan, &tracker_impl)?;
let exit = if result.failed_count > 0 { 1 } else { 0 };
let output = ReconcileOutput {
component_id,
command: command_label,
applied: true,
plan_summary,
result: Some(result),
plan_lines,
};
Ok((IssuesCommandOutput::Reconcile(output), exit))
} else {
let output = ReconcileOutput {
component_id,
command: command_label,
applied: false,
plan_summary,
result: None,
plan_lines,
};
Ok((IssuesCommandOutput::Reconcile(output), 0))
}
}
IssuesCommand::BuildFindings {
from_output,
run_url,
} => {
let findings_input = build_findings_input(&from_output, run_url)?;
Ok((IssuesCommandOutput::BuildFindings(findings_input), 0))
}
}
}
fn into_issue_groups(
input: ReconcileFindingsInput,
component_id: &str,
) -> Vec<homeboy::issues::IssueGroup> {
input
.groups
.into_iter()
.map(|(category, row)| homeboy::issues::IssueGroup {
command: input.command.clone(),
component_id: component_id.to_string(),
category,
count: row.count,
label: row.label,
body: row.body,
confidence: row.confidence,
})
.collect()
}
fn read_reconcile_input(
findings: Option<&str>,
from_output: &[(String, String)],
run_url: Option<String>,
) -> homeboy::Result<ReconcileFindingsInput> {
match (findings, from_output.is_empty()) {
(Some(path), true) => read_findings(path),
(None, false) => build_findings_input(from_output, run_url),
(Some(_), false) => Err(homeboy::Error::validation_invalid_argument(
"findings",
"Use either --findings or --from-output, not both",
None,
None,
)),
(None, true) => Err(homeboy::Error::validation_invalid_argument(
"findings",
"Missing --findings or --from-output",
None,
Some(vec![
"Pass --findings <path> for pre-rendered input".to_string(),
"Pass --from-output audit=<path> to normalize native command output".to_string(),
]),
)),
}
}
fn build_findings_input(
from_output: &[(String, String)],
run_url: Option<String>,
) -> homeboy::Result<ReconcileFindingsInput> {
if from_output.is_empty() {
return Err(homeboy::Error::validation_invalid_argument(
"from-output",
"At least one --from-output COMMAND=PATH pair is required",
None,
None,
));
}
let context = IssueRenderContext { run_url };
let mut merged = ReconcileFindingsInput::default();
let mut command_label: Option<&str> = None;
for (command, path) in from_output {
if let Some(existing) = command_label {
if existing != command {
return Err(homeboy::Error::validation_invalid_argument(
"from-output",
"Multiple command labels in one issue reconcile input are not supported yet",
None,
Some(vec![
"Run one reconcile per command label for now".to_string(),
"Use repeated --from-output only to merge split output files from the same command".to_string(),
]),
));
}
} else {
command_label = Some(command);
}
let value = read_json_value(path, "native command output")?;
let rendered = build_findings_from_native_output(command, value, &context)?;
merged.merge(rendered);
}
Ok(merged)
}
fn read_findings(path: &str) -> homeboy::Result<ReconcileFindingsInput> {
let value = read_json_value(path, "findings")?;
parse_findings_value(value)
}
fn read_json_value(path: &str, label: &str) -> homeboy::Result<Value> {
let raw = if path == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf).map_err(|e| {
homeboy::Error::internal_io(
format!("read {} from stdin: {}", label, e),
Some("stdin".into()),
)
})?;
buf
} else {
std::fs::read_to_string(path).map_err(|e| {
homeboy::Error::internal_io(
format!("read {} file: {}", label, e),
Some(path.to_string()),
)
})?
};
let value: Value = serde_json::from_str(&raw).map_err(|e| {
homeboy::Error::validation_invalid_json(
e,
Some("parse findings JSON".to_string()),
Some(raw.chars().take(200).collect()),
)
})?;
Ok(value)
}
fn parse_findings_value(value: Value) -> homeboy::Result<ReconcileFindingsInput> {
let obj = value.as_object().ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"findings",
"Findings JSON must be an object with a `command` and `groups` field",
None,
None,
)
})?;
let command = obj
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"findings.command",
"Missing or non-string `command` field (e.g. \"audit\")",
None,
None,
)
})?
.to_string();
let mut groups: BTreeMap<String, homeboy::issues::RenderedIssueGroup> = BTreeMap::new();
if let Some(groups_value) = obj.get("groups") {
let groups_obj = groups_value.as_object().ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"findings.groups",
"`groups` must be a JSON object keyed by category",
None,
None,
)
})?;
for (category, row_value) in groups_obj {
let row_obj = row_value.as_object().ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
format!("findings.groups.{}", category),
"Each group must be a JSON object with `count`, optional `label`, optional `body`, optional `confidence`",
None,
None,
)
})?;
let count = row_obj
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(0);
let label = row_obj
.get("label")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let body = row_obj
.get("body")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let confidence = row_obj
.get("confidence")
.and_then(|v| v.as_str())
.and_then(parse_confidence);
groups.insert(
category.clone(),
homeboy::issues::RenderedIssueGroup {
count,
label,
body,
confidence,
},
);
}
}
Ok(ReconcileFindingsInput { command, groups })
}
fn parse_confidence(raw: &str) -> Option<FindingConfidence> {
match raw.trim().to_ascii_lowercase().as_str() {
"structural" => Some(FindingConfidence::Structural),
"graph" => Some(FindingConfidence::Graph),
"heuristic" => Some(FindingConfidence::Heuristic),
_ => None,
}
}
fn build_reconcile_config(
component_id: &str,
path: Option<&str>,
suppress_from_config: bool,
cli_categories: Vec<String>,
cli_labels: Vec<String>,
cli_review_only_categories: Vec<String>,
no_refresh_closed: bool,
) -> homeboy::Result<ReconcileConfig> {
let mut config = ReconcileConfig {
refresh_closed_not_planned: !no_refresh_closed,
..ReconcileConfig::default()
};
if suppress_from_config {
if let Some(reconcile_config) = read_suppressions(component_id, path)? {
let (suppressed, labels, review_only) = reconcile_config;
config.suppressed_categories = suppressed;
config.suppression_labels = labels;
if let Some(review_only) = review_only {
config.review_only_categories = review_only;
}
}
}
if !cli_categories.is_empty() {
config.suppressed_categories = cli_categories;
}
if !cli_labels.is_empty() {
config.suppression_labels = cli_labels;
}
if !cli_review_only_categories.is_empty() {
config.review_only_categories = cli_review_only_categories;
}
if config.suppression_labels.is_empty() {
config.suppression_labels = vec![
"wontfix".into(),
"upstream-bug".into(),
"audit-suppressed".into(),
];
}
Ok(config)
}
fn read_suppressions(
component_id: &str,
path: Option<&str>,
) -> homeboy::Result<Option<(Vec<String>, Vec<String>, Option<Vec<String>>)>> {
let component_dir = match path {
Some(p) => PathBuf::from(p),
None => match homeboy::component::resolve_effective(Some(component_id), None, None) {
Ok(c) => PathBuf::from(c.local_path),
Err(_) => return Ok(None),
},
};
let raw = match homeboy::component::read_portable_config(&component_dir)? {
Some(v) => v,
None => return Ok(None),
};
let categories = raw
.pointer("/audit/suppressed_categories")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let labels = raw
.pointer("/issues/suppression_labels")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let review_only = raw
.pointer("/issues/review_only_categories")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect::<Vec<_>>()
});
if categories.is_empty() && labels.is_empty() && review_only.is_none() {
Ok(None)
} else {
Ok(Some((categories, labels, review_only)))
}
}
fn render_plan_lines(plan: &ReconcilePlan) -> Vec<String> {
plan.actions
.iter()
.map(|a| match a {
homeboy::issues::ReconcileAction::FileNew {
command,
component_id,
category,
count,
..
} => format!(
"file_new {}: {} in {} ({})",
command, category, component_id, count
),
homeboy::issues::ReconcileAction::Update {
number,
category,
count,
..
} => format!("update {} ({}) → #{}", category, count, number),
homeboy::issues::ReconcileAction::UpdateClosed {
number,
category,
count,
..
} => format!(
"update_closed {} ({}) → #{} (stays closed)",
category, count, number
),
homeboy::issues::ReconcileAction::Close {
number, category, ..
} => format!("close {} → #{}", category, number),
homeboy::issues::ReconcileAction::CloseDuplicate {
number,
keep,
category,
..
} => format!(
"dedupe {} → keep #{}, close #{}",
category, keep, number
),
homeboy::issues::ReconcileAction::Skip {
category, reason, ..
} => format!("skip {} ({:?})", category, reason),
})
.collect()
}
fn summarize_plan(plan: &ReconcilePlan) -> PlanSummary {
let counts = plan.counts();
PlanSummary {
total_actions: plan.actions.len(),
file_new: counts.file_new,
update: counts.update,
update_closed: counts.update_closed,
close: counts.close,
close_duplicate: counts.close_duplicate,
skip: counts.skip,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_findings_accepts_confidence_per_group() {
let input = serde_json::json!({
"command": "audit",
"groups": {
"god_file": {
"count": 2,
"label": "god file",
"body": "body",
"confidence": "heuristic"
}
}
});
let parsed = parse_findings_value(input).unwrap();
let groups = into_issue_groups(parsed, "homeboy");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].confidence, Some(FindingConfidence::Heuristic));
}
#[test]
fn default_reconcile_config_marks_thresholds_and_heuristics_review_only() {
let config = ReconcileConfig::default();
assert!(config.review_only_categories.contains(&"god_file".into()));
assert!(config
.review_only_categories
.contains(&"directory_sprawl".into()));
assert!(config
.review_only_categories
.contains(&"missing_test_file".into()));
assert!(config
.review_only_categories
.contains(&"parallel_implementation".into()));
assert!(config
.review_only_categories
.contains(&"unused_parameter".into()));
assert!(!config
.review_only_categories
.contains(&"unreferenced_export".into()));
assert!(!config
.review_only_categories
.contains(&"compiler_warning".into()));
}
}