From 293ef01e141846f7de5af2c8c6be9d6c694083fd Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 15:37:17 +0000 Subject: standardise on --label; add label filter and display to pr list/view - rename --hashtag (comma-separated) to --label (repeatable) on issue list, matching the --label flag already used on issue create - add --label filter to pr list with the same OR semantics (matching GitHub) - display labels column in pr list table and Labels: line in pr view - include labels array in all JSON outputs (list and view for both issue and pr) - rename internal 'hashtags' -> 'labels' throughout issue_list.rs and list.rs --- src/bin/ngit/cli.rs | 13 +++-- src/bin/ngit/main.rs | 19 +++++-- src/bin/ngit/sub_commands/issue_list.rs | 75 ++++++++++++-------------- src/bin/ngit/sub_commands/list.rs | 93 +++++++++++++++++++++++++++++---- 4 files changed, 140 insertions(+), 60 deletions(-) (limited to 'src') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 0599b51..5ee9165 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -177,6 +177,10 @@ pub enum PrCommands { /// Filter by status (comma-separated: open,draft,closed,applied) #[arg(long, default_value = "open,draft")] status: String, + /// Filter by label (repeatable, OR logic: --label bug --label + /// help-wanted) + #[arg(long = "label", value_name = "LABEL")] + labels: Vec, /// Output as JSON #[arg(long)] json: bool, @@ -311,9 +315,10 @@ pub enum IssueCommands { /// Filter by status (comma-separated: open,draft,closed,applied) #[arg(long, default_value = "open")] status: String, - /// Filter by hashtag/label (comma-separated) - #[arg(long)] - hashtag: Option, + /// Filter by label (repeatable, OR logic: --label bug --label + /// help-wanted) + #[arg(long = "label", value_name = "LABEL")] + labels: Vec, /// Output as JSON #[arg(long)] json: bool, @@ -347,7 +352,7 @@ pub enum IssueCommands { /// Issue body / description #[arg(long)] body: Option, - /// Hashtag labels (repeatable: --label bug --label help-wanted) + /// Labels to apply (repeatable: --label bug --label help-wanted) #[arg(long = "label", value_name = "LABEL")] labels: Vec, }, diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 4c1aa75..28bf1da 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -57,12 +57,20 @@ async fn main() { Commands::Pr(args) => match &args.pr_command { PrCommands::List { status, + labels, json, id, offline, } => { - sub_commands::list::launch(status.clone(), *json, false, id.clone(), *offline) - .await + sub_commands::list::launch( + status.clone(), + labels.clone(), + *json, + false, + id.clone(), + *offline, + ) + .await } PrCommands::View { id, @@ -72,6 +80,7 @@ async fn main() { } => { sub_commands::list::launch( "open,draft,closed,applied".to_string(), + vec![], *json, *comments, Some(id.clone()), @@ -122,14 +131,14 @@ async fn main() { Commands::Issue(args) => match &args.issue_command { IssueCommands::List { status, - hashtag, + labels, json, id, offline, } => { sub_commands::issue_list::launch( status.clone(), - hashtag.clone(), + labels.clone(), *json, false, id.clone(), @@ -145,7 +154,7 @@ async fn main() { } => { sub_commands::issue_list::launch( "open,draft,closed,applied".to_string(), - None, + vec![], *json, *comments, Some(id.clone()), diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index 864cd76..d7c8ac9 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -38,7 +38,7 @@ fn get_issue_title(event: &nostr::Event) -> String { }) } -fn get_issue_hashtags(event: &nostr::Event) -> Vec { +fn get_issue_labels(event: &nostr::Event) -> Vec { event .tags .iter() @@ -136,7 +136,7 @@ async fn get_comments_for_issue( #[allow(clippy::too_many_lines)] pub async fn launch( status: String, - hashtag: Option, + labels: Vec, json: bool, show_comments: bool, id: Option, @@ -188,11 +188,8 @@ pub async fn launch( let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); - let hashtag_filter: Option> = hashtag.map(|h| { - h.split(',') - .map(|s| s.trim().to_lowercase()) - .collect::>() - }); + // OR filter: issue must have at least one of the requested labels. + let label_filter: HashSet = labels.iter().map(|l| l.trim().to_lowercase()).collect(); // Use an empty vec as the "all_pr_roots" argument — issues don't have PR // revisions, so we pass an empty slice. @@ -206,16 +203,16 @@ pub async fn launch( if !status_filter.contains(status_str) && !status_filter.contains("unknown") { return None; } - let tags = get_issue_hashtags(issue); - if let Some(ref hf) = hashtag_filter { - let issue_tags_lower: HashSet = - tags.iter().map(|t| t.to_lowercase()).collect(); - if !hf.iter().any(|h| issue_tags_lower.contains(h)) { + let issue_labels = get_issue_labels(issue); + if !label_filter.is_empty() { + let issue_labels_lower: HashSet = + issue_labels.iter().map(|t| t.to_lowercase()).collect(); + if !label_filter.iter().any(|l| issue_labels_lower.contains(l)) { return None; } } let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); - Some((issue, status_kind, tags, comment_count)) + Some((issue, status_kind, issue_labels, comment_count)) }) .collect(); @@ -254,7 +251,7 @@ pub async fn launch( if json { output_json(&filtered)?; } else { - output_table(&filtered, &status, hashtag_filter.as_ref()); + output_table(&filtered, &status, &label_filter); } Ok(()) @@ -305,7 +302,7 @@ fn show_issue_details( nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? }; - let (issue, status_kind, tags, comment_count) = issues + let (issue, status_kind, labels, comment_count) = issues .iter() .find(|(e, _, _, _)| e.id == target_id) .context("issue not found")?; @@ -333,7 +330,7 @@ fn show_issue_details( "status": status, "title": title, "author": issue.pubkey.to_bech32().unwrap_or_default(), - "hashtags": tags, + "labels": labels, "comment_count": comment_count, "comments": comments_json, "description": issue.content, @@ -344,7 +341,7 @@ fn show_issue_details( "status": status, "title": title, "author": issue.pubkey.to_bech32().unwrap_or_default(), - "hashtags": tags, + "labels": labels, "comment_count": comment_count, "description": issue.content, }) @@ -356,13 +353,13 @@ fn show_issue_details( println!("Title: {title}"); println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); println!("Status: {status}"); - if !tags.is_empty() { - let tags_str = tags + if !labels.is_empty() { + let labels_str = labels .iter() - .map(|t| format!("#{t}")) + .map(|l| format!("#{l}")) .collect::>() .join(" "); - println!("Tags: {tags_str}"); + println!("Labels: {labels_str}"); } if !issue.content.is_empty() { @@ -425,39 +422,35 @@ fn chrono_timestamp(unix_secs: u64) -> String { fn output_table( issues: &[(&nostr::Event, Kind, Vec, usize)], status_filter: &str, - hashtag_filter: Option<&HashSet>, + label_filter: &HashSet, ) { - println!("{:<66} {:<8} {:<5} TITLE HASHTAGS", "ID", "STATUS", "CMTS"); - for (issue, status_kind, tags, comment_count) in issues { + println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); + for (issue, status_kind, labels, comment_count) in issues { let id = issue.id.to_string(); let status = status_kind_to_str(*status_kind); let title = get_issue_title(issue); - let tags_str = if tags.is_empty() { + let labels_str = if labels.is_empty() { String::new() } else { - tags.iter() - .map(|t| format!("#{t}")) + labels + .iter() + .map(|l| format!("#{l}")) .collect::>() .join(" ") }; - if tags_str.is_empty() { + if labels_str.is_empty() { println!("{id:<66} {status:<8} {comment_count:<5} {title}"); } else { - println!("{id:<66} {status:<8} {comment_count:<5} {title} {tags_str}"); + println!("{id:<66} {status:<8} {comment_count:<5} {title} {labels_str}"); } } println!(); print!("--status {status_filter}"); - if let Some(hf) = hashtag_filter { - let tags: Vec<&String> = hf.iter().collect(); - print!( - " --hashtag {}", - tags.iter() - .map(|s| s.as_str()) - .collect::>() - .join(",") - ); + if !label_filter.is_empty() { + for l in label_filter { + print!(" --label {l}"); + } } println!(); } @@ -465,14 +458,14 @@ fn output_table( fn output_json(issues: &[(&nostr::Event, Kind, Vec, usize)]) -> Result<()> { let json_output: Vec = issues .iter() - .map(|(issue, status_kind, tags, comment_count)| { + .map(|(issue, status_kind, labels, comment_count)| { serde_json::json!({ "id": issue.id.to_string(), "status": status_kind_to_str(*status_kind), "title": get_issue_title(issue), "author": issue.pubkey.to_bech32().unwrap_or_default(), - "hashtags": tags, - "comments": comment_count, + "labels": labels, + "comment_count": comment_count, }) }) .collect(); diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index 60e129f..547c051 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -97,6 +97,7 @@ fn run_git_fetch(remote_name: &str) -> Result<()> { #[allow(clippy::too_many_lines)] pub async fn launch( status: String, + labels: Vec, json: bool, show_comments: bool, id: Option, @@ -187,6 +188,9 @@ pub async fn launch( let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); + // OR filter: proposal must have at least one of the requested labels. + let label_filter: HashSet = labels.iter().map(|l| l.trim().to_lowercase()).collect(); + let filtered_proposals: Vec<(&nostr::Event, Kind)> = proposals .iter() .filter_map(|p| { @@ -198,11 +202,24 @@ pub async fn launch( Kind::GitStatusApplied => "applied", _ => "unknown", }; - if status_filter.contains(status_str) || status_filter.contains("unknown") { - Some((p, status_kind)) - } else { - None + if !status_filter.contains(status_str) && !status_filter.contains("unknown") { + return None; } + if !label_filter.is_empty() { + let proposal_labels: HashSet = p + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| t.as_slice()[1].to_lowercase()) + .collect(); + if !label_filter.iter().any(|l| proposal_labels.contains(l)) { + return None; + } + } + Some((p, status_kind)) }) .collect(); @@ -236,7 +253,7 @@ pub async fn launch( if json { output_json(&filtered_proposals, &repo_ref)?; } else { - output_table(&filtered_proposals, &repo_ref, &status); + output_table(&filtered_proposals, &repo_ref, &status, &label_filter); } Ok(()) @@ -299,13 +316,18 @@ fn status_kind_to_str(kind: Kind) -> &'static str { } } -fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status_filter: &str) { +fn output_table( + proposals: &[(&nostr::Event, Kind)], + _repo_ref: &RepoRef, + status_filter: &str, + label_filter: &HashSet, +) { if proposals.is_empty() { println!("No proposals found matching status: {status_filter}"); return; } - println!("{:<66} {:<8} TITLE", "ID", "STATUS"); + println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); for (proposal, status_kind) in proposals { let id = proposal.id.to_string(); let status = status_kind_to_str(*status_kind); @@ -316,11 +338,31 @@ fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status } else { proposal.id.to_string() }; - println!("{id:<66} {status:<8} {title}"); + let labels_str: String = proposal + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| format!("#{}", t.as_slice()[1])) + .collect::>() + .join(" "); + if labels_str.is_empty() { + println!("{id:<66} {status:<8} {title}"); + } else { + println!("{id:<66} {status:<8} {title} {labels_str}"); + } } println!(); - println!("--status {status_filter}"); + print!("--status {status_filter}"); + if !label_filter.is_empty() { + for l in label_filter { + print!(" --label {l}"); + } + } + println!(); println!( "{}", console::style("To view: ngit pr view ").yellow() @@ -359,12 +401,22 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu String::new(), ) }; + let labels: Vec = proposal + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| t.as_slice()[1].clone()) + .collect(); serde_json::json!({ "id": id, "status": status, "title": title, "author": author, - "branch": branch + "branch": branch, + "labels": labels, }) }) .collect(); @@ -400,6 +452,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { }) } +#[allow(clippy::too_many_lines)] fn show_proposal_details( proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, @@ -421,6 +474,16 @@ fn show_proposal_details( let cover_letter = event_to_cover_letter(proposal) .context("failed to extract proposal details from proposal root event")?; + let proposal_labels: Vec = proposal + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| t.as_slice()[1].clone()) + .collect(); + if json { let json_output = if show_comments { let comments_json: Vec = comments @@ -442,6 +505,7 @@ fn show_proposal_details( "title": cover_letter.title, "author": proposal.pubkey.to_bech32().unwrap_or_default(), "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, + "labels": proposal_labels, "comment_count": comment_count, "comments": comments_json, "description": cover_letter.description, @@ -453,6 +517,7 @@ fn show_proposal_details( "title": cover_letter.title, "author": proposal.pubkey.to_bech32().unwrap_or_default(), "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, + "labels": proposal_labels, "comment_count": comment_count, "description": cover_letter.description, }) @@ -471,6 +536,14 @@ fn show_proposal_details( "Branch: {}", cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? ); + if !proposal_labels.is_empty() { + let labels_str = proposal_labels + .iter() + .map(|l| format!("#{l}")) + .collect::>() + .join(" "); + println!("Labels: {labels_str}"); + } if !cover_letter.description.is_empty() { println!(); -- cgit v1.2.3