upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 15:37:17 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 15:37:17 +0000
commit293ef01e141846f7de5af2c8c6be9d6c694083fd (patch)
treed4ee20d9a89fe622fcfa058e600fdd25e56eab91
parentb4c7b5e24d05aef878e155a1bedc22de54609fbb (diff)
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
-rw-r--r--src/bin/ngit/cli.rs13
-rw-r--r--src/bin/ngit/main.rs19
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs75
-rw-r--r--src/bin/ngit/sub_commands/list.rs93
4 files changed, 140 insertions, 60 deletions
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 {
177 /// Filter by status (comma-separated: open,draft,closed,applied) 177 /// Filter by status (comma-separated: open,draft,closed,applied)
178 #[arg(long, default_value = "open,draft")] 178 #[arg(long, default_value = "open,draft")]
179 status: String, 179 status: String,
180 /// Filter by label (repeatable, OR logic: --label bug --label
181 /// help-wanted)
182 #[arg(long = "label", value_name = "LABEL")]
183 labels: Vec<String>,
180 /// Output as JSON 184 /// Output as JSON
181 #[arg(long)] 185 #[arg(long)]
182 json: bool, 186 json: bool,
@@ -311,9 +315,10 @@ pub enum IssueCommands {
311 /// Filter by status (comma-separated: open,draft,closed,applied) 315 /// Filter by status (comma-separated: open,draft,closed,applied)
312 #[arg(long, default_value = "open")] 316 #[arg(long, default_value = "open")]
313 status: String, 317 status: String,
314 /// Filter by hashtag/label (comma-separated) 318 /// Filter by label (repeatable, OR logic: --label bug --label
315 #[arg(long)] 319 /// help-wanted)
316 hashtag: Option<String>, 320 #[arg(long = "label", value_name = "LABEL")]
321 labels: Vec<String>,
317 /// Output as JSON 322 /// Output as JSON
318 #[arg(long)] 323 #[arg(long)]
319 json: bool, 324 json: bool,
@@ -347,7 +352,7 @@ pub enum IssueCommands {
347 /// Issue body / description 352 /// Issue body / description
348 #[arg(long)] 353 #[arg(long)]
349 body: Option<String>, 354 body: Option<String>,
350 /// Hashtag labels (repeatable: --label bug --label help-wanted) 355 /// Labels to apply (repeatable: --label bug --label help-wanted)
351 #[arg(long = "label", value_name = "LABEL")] 356 #[arg(long = "label", value_name = "LABEL")]
352 labels: Vec<String>, 357 labels: Vec<String>,
353 }, 358 },
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() {
57 Commands::Pr(args) => match &args.pr_command { 57 Commands::Pr(args) => match &args.pr_command {
58 PrCommands::List { 58 PrCommands::List {
59 status, 59 status,
60 labels,
60 json, 61 json,
61 id, 62 id,
62 offline, 63 offline,
63 } => { 64 } => {
64 sub_commands::list::launch(status.clone(), *json, false, id.clone(), *offline) 65 sub_commands::list::launch(
65 .await 66 status.clone(),
67 labels.clone(),
68 *json,
69 false,
70 id.clone(),
71 *offline,
72 )
73 .await
66 } 74 }
67 PrCommands::View { 75 PrCommands::View {
68 id, 76 id,
@@ -72,6 +80,7 @@ async fn main() {
72 } => { 80 } => {
73 sub_commands::list::launch( 81 sub_commands::list::launch(
74 "open,draft,closed,applied".to_string(), 82 "open,draft,closed,applied".to_string(),
83 vec![],
75 *json, 84 *json,
76 *comments, 85 *comments,
77 Some(id.clone()), 86 Some(id.clone()),
@@ -122,14 +131,14 @@ async fn main() {
122 Commands::Issue(args) => match &args.issue_command { 131 Commands::Issue(args) => match &args.issue_command {
123 IssueCommands::List { 132 IssueCommands::List {
124 status, 133 status,
125 hashtag, 134 labels,
126 json, 135 json,
127 id, 136 id,
128 offline, 137 offline,
129 } => { 138 } => {
130 sub_commands::issue_list::launch( 139 sub_commands::issue_list::launch(
131 status.clone(), 140 status.clone(),
132 hashtag.clone(), 141 labels.clone(),
133 *json, 142 *json,
134 false, 143 false,
135 id.clone(), 144 id.clone(),
@@ -145,7 +154,7 @@ async fn main() {
145 } => { 154 } => {
146 sub_commands::issue_list::launch( 155 sub_commands::issue_list::launch(
147 "open,draft,closed,applied".to_string(), 156 "open,draft,closed,applied".to_string(),
148 None, 157 vec![],
149 *json, 158 *json,
150 *comments, 159 *comments,
151 Some(id.clone()), 160 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 {
38 }) 38 })
39} 39}
40 40
41fn get_issue_hashtags(event: &nostr::Event) -> Vec<String> { 41fn get_issue_labels(event: &nostr::Event) -> Vec<String> {
42 event 42 event
43 .tags 43 .tags
44 .iter() 44 .iter()
@@ -136,7 +136,7 @@ async fn get_comments_for_issue(
136#[allow(clippy::too_many_lines)] 136#[allow(clippy::too_many_lines)]
137pub async fn launch( 137pub async fn launch(
138 status: String, 138 status: String,
139 hashtag: Option<String>, 139 labels: Vec<String>,
140 json: bool, 140 json: bool,
141 show_comments: bool, 141 show_comments: bool,
142 id: Option<String>, 142 id: Option<String>,
@@ -188,11 +188,8 @@ pub async fn launch(
188 188
189 let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); 189 let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect();
190 190
191 let hashtag_filter: Option<HashSet<String>> = hashtag.map(|h| { 191 // OR filter: issue must have at least one of the requested labels.
192 h.split(',') 192 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
193 .map(|s| s.trim().to_lowercase())
194 .collect::<HashSet<String>>()
195 });
196 193
197 // Use an empty vec as the "all_pr_roots" argument — issues don't have PR 194 // Use an empty vec as the "all_pr_roots" argument — issues don't have PR
198 // revisions, so we pass an empty slice. 195 // revisions, so we pass an empty slice.
@@ -206,16 +203,16 @@ pub async fn launch(
206 if !status_filter.contains(status_str) && !status_filter.contains("unknown") { 203 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
207 return None; 204 return None;
208 } 205 }
209 let tags = get_issue_hashtags(issue); 206 let issue_labels = get_issue_labels(issue);
210 if let Some(ref hf) = hashtag_filter { 207 if !label_filter.is_empty() {
211 let issue_tags_lower: HashSet<String> = 208 let issue_labels_lower: HashSet<String> =
212 tags.iter().map(|t| t.to_lowercase()).collect(); 209 issue_labels.iter().map(|t| t.to_lowercase()).collect();
213 if !hf.iter().any(|h| issue_tags_lower.contains(h)) { 210 if !label_filter.iter().any(|l| issue_labels_lower.contains(l)) {
214 return None; 211 return None;
215 } 212 }
216 } 213 }
217 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); 214 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0);
218 Some((issue, status_kind, tags, comment_count)) 215 Some((issue, status_kind, issue_labels, comment_count))
219 }) 216 })
220 .collect(); 217 .collect();
221 218
@@ -254,7 +251,7 @@ pub async fn launch(
254 if json { 251 if json {
255 output_json(&filtered)?; 252 output_json(&filtered)?;
256 } else { 253 } else {
257 output_table(&filtered, &status, hashtag_filter.as_ref()); 254 output_table(&filtered, &status, &label_filter);
258 } 255 }
259 256
260 Ok(()) 257 Ok(())
@@ -305,7 +302,7 @@ fn show_issue_details(
305 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? 302 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
306 }; 303 };
307 304
308 let (issue, status_kind, tags, comment_count) = issues 305 let (issue, status_kind, labels, comment_count) = issues
309 .iter() 306 .iter()
310 .find(|(e, _, _, _)| e.id == target_id) 307 .find(|(e, _, _, _)| e.id == target_id)
311 .context("issue not found")?; 308 .context("issue not found")?;
@@ -333,7 +330,7 @@ fn show_issue_details(
333 "status": status, 330 "status": status,
334 "title": title, 331 "title": title,
335 "author": issue.pubkey.to_bech32().unwrap_or_default(), 332 "author": issue.pubkey.to_bech32().unwrap_or_default(),
336 "hashtags": tags, 333 "labels": labels,
337 "comment_count": comment_count, 334 "comment_count": comment_count,
338 "comments": comments_json, 335 "comments": comments_json,
339 "description": issue.content, 336 "description": issue.content,
@@ -344,7 +341,7 @@ fn show_issue_details(
344 "status": status, 341 "status": status,
345 "title": title, 342 "title": title,
346 "author": issue.pubkey.to_bech32().unwrap_or_default(), 343 "author": issue.pubkey.to_bech32().unwrap_or_default(),
347 "hashtags": tags, 344 "labels": labels,
348 "comment_count": comment_count, 345 "comment_count": comment_count,
349 "description": issue.content, 346 "description": issue.content,
350 }) 347 })
@@ -356,13 +353,13 @@ fn show_issue_details(
356 println!("Title: {title}"); 353 println!("Title: {title}");
357 println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); 354 println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default());
358 println!("Status: {status}"); 355 println!("Status: {status}");
359 if !tags.is_empty() { 356 if !labels.is_empty() {
360 let tags_str = tags 357 let labels_str = labels
361 .iter() 358 .iter()
362 .map(|t| format!("#{t}")) 359 .map(|l| format!("#{l}"))
363 .collect::<Vec<_>>() 360 .collect::<Vec<_>>()
364 .join(" "); 361 .join(" ");
365 println!("Tags: {tags_str}"); 362 println!("Labels: {labels_str}");
366 } 363 }
367 364
368 if !issue.content.is_empty() { 365 if !issue.content.is_empty() {
@@ -425,39 +422,35 @@ fn chrono_timestamp(unix_secs: u64) -> String {
425fn output_table( 422fn output_table(
426 issues: &[(&nostr::Event, Kind, Vec<String>, usize)], 423 issues: &[(&nostr::Event, Kind, Vec<String>, usize)],
427 status_filter: &str, 424 status_filter: &str,
428 hashtag_filter: Option<&HashSet<String>>, 425 label_filter: &HashSet<String>,
429) { 426) {
430 println!("{:<66} {:<8} {:<5} TITLE HASHTAGS", "ID", "STATUS", "CMTS"); 427 println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS");
431 for (issue, status_kind, tags, comment_count) in issues { 428 for (issue, status_kind, labels, comment_count) in issues {
432 let id = issue.id.to_string(); 429 let id = issue.id.to_string();
433 let status = status_kind_to_str(*status_kind); 430 let status = status_kind_to_str(*status_kind);
434 let title = get_issue_title(issue); 431 let title = get_issue_title(issue);
435 let tags_str = if tags.is_empty() { 432 let labels_str = if labels.is_empty() {
436 String::new() 433 String::new()
437 } else { 434 } else {
438 tags.iter() 435 labels
439 .map(|t| format!("#{t}")) 436 .iter()
437 .map(|l| format!("#{l}"))
440 .collect::<Vec<_>>() 438 .collect::<Vec<_>>()
441 .join(" ") 439 .join(" ")
442 }; 440 };
443 if tags_str.is_empty() { 441 if labels_str.is_empty() {
444 println!("{id:<66} {status:<8} {comment_count:<5} {title}"); 442 println!("{id:<66} {status:<8} {comment_count:<5} {title}");
445 } else { 443 } else {
446 println!("{id:<66} {status:<8} {comment_count:<5} {title} {tags_str}"); 444 println!("{id:<66} {status:<8} {comment_count:<5} {title} {labels_str}");
447 } 445 }
448 } 446 }
449 447
450 println!(); 448 println!();
451 print!("--status {status_filter}"); 449 print!("--status {status_filter}");
452 if let Some(hf) = hashtag_filter { 450 if !label_filter.is_empty() {
453 let tags: Vec<&String> = hf.iter().collect(); 451 for l in label_filter {
454 print!( 452 print!(" --label {l}");
455 " --hashtag {}", 453 }
456 tags.iter()
457 .map(|s| s.as_str())
458 .collect::<Vec<_>>()
459 .join(",")
460 );
461 } 454 }
462 println!(); 455 println!();
463} 456}
@@ -465,14 +458,14 @@ fn output_table(
465fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>, usize)]) -> Result<()> { 458fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>, usize)]) -> Result<()> {
466 let json_output: Vec<serde_json::Value> = issues 459 let json_output: Vec<serde_json::Value> = issues
467 .iter() 460 .iter()
468 .map(|(issue, status_kind, tags, comment_count)| { 461 .map(|(issue, status_kind, labels, comment_count)| {
469 serde_json::json!({ 462 serde_json::json!({
470 "id": issue.id.to_string(), 463 "id": issue.id.to_string(),
471 "status": status_kind_to_str(*status_kind), 464 "status": status_kind_to_str(*status_kind),
472 "title": get_issue_title(issue), 465 "title": get_issue_title(issue),
473 "author": issue.pubkey.to_bech32().unwrap_or_default(), 466 "author": issue.pubkey.to_bech32().unwrap_or_default(),
474 "hashtags": tags, 467 "labels": labels,
475 "comments": comment_count, 468 "comment_count": comment_count,
476 }) 469 })
477 }) 470 })
478 .collect(); 471 .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<()> {
97#[allow(clippy::too_many_lines)] 97#[allow(clippy::too_many_lines)]
98pub async fn launch( 98pub async fn launch(
99 status: String, 99 status: String,
100 labels: Vec<String>,
100 json: bool, 101 json: bool,
101 show_comments: bool, 102 show_comments: bool,
102 id: Option<String>, 103 id: Option<String>,
@@ -187,6 +188,9 @@ pub async fn launch(
187 188
188 let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); 189 let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect();
189 190
191 // OR filter: proposal must have at least one of the requested labels.
192 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
193
190 let filtered_proposals: Vec<(&nostr::Event, Kind)> = proposals 194 let filtered_proposals: Vec<(&nostr::Event, Kind)> = proposals
191 .iter() 195 .iter()
192 .filter_map(|p| { 196 .filter_map(|p| {
@@ -198,11 +202,24 @@ pub async fn launch(
198 Kind::GitStatusApplied => "applied", 202 Kind::GitStatusApplied => "applied",
199 _ => "unknown", 203 _ => "unknown",
200 }; 204 };
201 if status_filter.contains(status_str) || status_filter.contains("unknown") { 205 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
202 Some((p, status_kind)) 206 return None;
203 } else {
204 None
205 } 207 }
208 if !label_filter.is_empty() {
209 let proposal_labels: HashSet<String> = p
210 .tags
211 .iter()
212 .filter(|t| {
213 let s = t.as_slice();
214 s.len() >= 2 && s[0].eq("t")
215 })
216 .map(|t| t.as_slice()[1].to_lowercase())
217 .collect();
218 if !label_filter.iter().any(|l| proposal_labels.contains(l)) {
219 return None;
220 }
221 }
222 Some((p, status_kind))
206 }) 223 })
207 .collect(); 224 .collect();
208 225
@@ -236,7 +253,7 @@ pub async fn launch(
236 if json { 253 if json {
237 output_json(&filtered_proposals, &repo_ref)?; 254 output_json(&filtered_proposals, &repo_ref)?;
238 } else { 255 } else {
239 output_table(&filtered_proposals, &repo_ref, &status); 256 output_table(&filtered_proposals, &repo_ref, &status, &label_filter);
240 } 257 }
241 258
242 Ok(()) 259 Ok(())
@@ -299,13 +316,18 @@ fn status_kind_to_str(kind: Kind) -> &'static str {
299 } 316 }
300} 317}
301 318
302fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status_filter: &str) { 319fn output_table(
320 proposals: &[(&nostr::Event, Kind)],
321 _repo_ref: &RepoRef,
322 status_filter: &str,
323 label_filter: &HashSet<String>,
324) {
303 if proposals.is_empty() { 325 if proposals.is_empty() {
304 println!("No proposals found matching status: {status_filter}"); 326 println!("No proposals found matching status: {status_filter}");
305 return; 327 return;
306 } 328 }
307 329
308 println!("{:<66} {:<8} TITLE", "ID", "STATUS"); 330 println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS");
309 for (proposal, status_kind) in proposals { 331 for (proposal, status_kind) in proposals {
310 let id = proposal.id.to_string(); 332 let id = proposal.id.to_string();
311 let status = status_kind_to_str(*status_kind); 333 let status = status_kind_to_str(*status_kind);
@@ -316,11 +338,31 @@ fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status
316 } else { 338 } else {
317 proposal.id.to_string() 339 proposal.id.to_string()
318 }; 340 };
319 println!("{id:<66} {status:<8} {title}"); 341 let labels_str: String = proposal
342 .tags
343 .iter()
344 .filter(|t| {
345 let s = t.as_slice();
346 s.len() >= 2 && s[0].eq("t")
347 })
348 .map(|t| format!("#{}", t.as_slice()[1]))
349 .collect::<Vec<_>>()
350 .join(" ");
351 if labels_str.is_empty() {
352 println!("{id:<66} {status:<8} {title}");
353 } else {
354 println!("{id:<66} {status:<8} {title} {labels_str}");
355 }
320 } 356 }
321 357
322 println!(); 358 println!();
323 println!("--status {status_filter}"); 359 print!("--status {status_filter}");
360 if !label_filter.is_empty() {
361 for l in label_filter {
362 print!(" --label {l}");
363 }
364 }
365 println!();
324 println!( 366 println!(
325 "{}", 367 "{}",
326 console::style("To view: ngit pr view <id>").yellow() 368 console::style("To view: ngit pr view <id>").yellow()
@@ -359,12 +401,22 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu
359 String::new(), 401 String::new(),
360 ) 402 )
361 }; 403 };
404 let labels: Vec<String> = proposal
405 .tags
406 .iter()
407 .filter(|t| {
408 let s = t.as_slice();
409 s.len() >= 2 && s[0].eq("t")
410 })
411 .map(|t| t.as_slice()[1].clone())
412 .collect();
362 serde_json::json!({ 413 serde_json::json!({
363 "id": id, 414 "id": id,
364 "status": status, 415 "status": status,
365 "title": title, 416 "title": title,
366 "author": author, 417 "author": author,
367 "branch": branch 418 "branch": branch,
419 "labels": labels,
368 }) 420 })
369 }) 421 })
370 .collect(); 422 .collect();
@@ -400,6 +452,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
400 }) 452 })
401} 453}
402 454
455#[allow(clippy::too_many_lines)]
403fn show_proposal_details( 456fn show_proposal_details(
404 proposals: &[(&nostr::Event, Kind)], 457 proposals: &[(&nostr::Event, Kind)],
405 _repo_ref: &RepoRef, 458 _repo_ref: &RepoRef,
@@ -421,6 +474,16 @@ fn show_proposal_details(
421 let cover_letter = event_to_cover_letter(proposal) 474 let cover_letter = event_to_cover_letter(proposal)
422 .context("failed to extract proposal details from proposal root event")?; 475 .context("failed to extract proposal details from proposal root event")?;
423 476
477 let proposal_labels: Vec<String> = proposal
478 .tags
479 .iter()
480 .filter(|t| {
481 let s = t.as_slice();
482 s.len() >= 2 && s[0].eq("t")
483 })
484 .map(|t| t.as_slice()[1].clone())
485 .collect();
486
424 if json { 487 if json {
425 let json_output = if show_comments { 488 let json_output = if show_comments {
426 let comments_json: Vec<serde_json::Value> = comments 489 let comments_json: Vec<serde_json::Value> = comments
@@ -442,6 +505,7 @@ fn show_proposal_details(
442 "title": cover_letter.title, 505 "title": cover_letter.title,
443 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 506 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
444 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 507 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
508 "labels": proposal_labels,
445 "comment_count": comment_count, 509 "comment_count": comment_count,
446 "comments": comments_json, 510 "comments": comments_json,
447 "description": cover_letter.description, 511 "description": cover_letter.description,
@@ -453,6 +517,7 @@ fn show_proposal_details(
453 "title": cover_letter.title, 517 "title": cover_letter.title,
454 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 518 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
455 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 519 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
520 "labels": proposal_labels,
456 "comment_count": comment_count, 521 "comment_count": comment_count,
457 "description": cover_letter.description, 522 "description": cover_letter.description,
458 }) 523 })
@@ -471,6 +536,14 @@ fn show_proposal_details(
471 "Branch: {}", 536 "Branch: {}",
472 cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? 537 cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?
473 ); 538 );
539 if !proposal_labels.is_empty() {
540 let labels_str = proposal_labels
541 .iter()
542 .map(|l| format!("#{l}"))
543 .collect::<Vec<_>>()
544 .join(" ");
545 println!("Labels: {labels_str}");
546 }
474 547
475 if !cover_letter.description.is_empty() { 548 if !cover_letter.description.is_empty() {
476 println!(); 549 println!();