upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/list.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 13:03:50 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 13:03:50 +0000
commitf3fcf863aae000964753f574b00e9fb9f5fcd452 (patch)
tree522e1cff8e0b8ab9fcabcf1bc6d229076891542e /src/bin/ngit/sub_commands/list.rs
parentad6c39abdc35603f58e9b71993b5632c976deac1 (diff)
feat(subject): add pr/issue set-subject via NIP-32 kind-1985 labels
Adds the ability to update the displayed title of a PR or issue after creation using a kind-1985 label event with the #subject namespace. Only the author or a repository maintainer may set the subject. The latest authorised event wins with tiebreak by lexicographically larger event ID (NIP-1 replaceable event semantics). Branch names and commit messages are never affected. - Split get_labels() into process_labels() (additive #t) and process_subject() (replaceable-style #subject), with a shared get_labels_and_subject() entry point that processes both from a single pre-fetched slice of kind-1985 events - All list/view/JSON display paths apply the subject override silently - New ngit pr set-subject <id> --subject <text> command - New ngit issue set-subject <id> --subject <text> command
Diffstat (limited to 'src/bin/ngit/sub_commands/list.rs')
-rw-r--r--src/bin/ngit/sub_commands/list.rs68
1 files changed, 42 insertions, 26 deletions
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index 404b25e..ab4f0f7 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -16,7 +16,7 @@ use ngit::{
16 fetch::fetch_from_git_server, 16 fetch::fetch_from_git_server,
17 git_events::{ 17 git_events::{
18 KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, 18 KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
19 get_commit_id_from_patch, get_labels, 19 get_commit_id_from_patch, get_labels_and_subject,
20 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, 20 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value,
21 }, 21 },
22 repo_ref::{RepoRef, is_grasp_server_in_list}, 22 repo_ref::{RepoRef, is_grasp_server_in_list},
@@ -203,7 +203,7 @@ pub async fn launch(
203 // OR filter: proposal must have at least one of the requested labels. 203 // OR filter: proposal must have at least one of the requested labels.
204 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect(); 204 let label_filter: HashSet<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
205 205
206 let filtered_proposals: Vec<(&nostr::Event, Kind, Vec<String>)> = proposals 206 let filtered_proposals: Vec<(&nostr::Event, Kind, Vec<String>, Option<String>)> = proposals
207 .iter() 207 .iter()
208 .filter_map(|p| { 208 .filter_map(|p| {
209 let status_kind = get_status(p, &repo_ref, &statuses, &proposals); 209 let status_kind = get_status(p, &repo_ref, &statuses, &proposals);
@@ -217,7 +217,8 @@ pub async fn launch(
217 if !status_filter.contains(status_str) && !status_filter.contains("unknown") { 217 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
218 return None; 218 return None;
219 } 219 }
220 let proposal_labels = get_labels(p, &repo_ref, &label_events); 220 let (proposal_labels, subject_override) =
221 get_labels_and_subject(p, &repo_ref, &label_events);
221 if !label_filter.is_empty() { 222 if !label_filter.is_empty() {
222 let proposal_labels_lower: HashSet<String> = 223 let proposal_labels_lower: HashSet<String> =
223 proposal_labels.iter().map(|l| l.to_lowercase()).collect(); 224 proposal_labels.iter().map(|l| l.to_lowercase()).collect();
@@ -225,7 +226,7 @@ pub async fn launch(
225 return None; 226 return None;
226 } 227 }
227 } 228 }
228 Some((p, status_kind, proposal_labels)) 229 Some((p, status_kind, proposal_labels, subject_override))
229 }) 230 })
230 .collect(); 231 .collect();
231 232
@@ -321,8 +322,21 @@ fn status_kind_to_str(kind: Kind) -> &'static str {
321 } 322 }
322} 323}
323 324
325fn proposal_title(proposal: &nostr::Event, subject_override: Option<&str>) -> String {
326 if let Some(s) = subject_override {
327 return s.to_string();
328 }
329 if let Ok(cl) = event_to_cover_letter(proposal) {
330 cl.title
331 } else if let Ok(msg) = tag_value(proposal, "description") {
332 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
333 } else {
334 proposal.id.to_string()
335 }
336}
337
324fn output_table( 338fn output_table(
325 proposals: &[(&nostr::Event, Kind, Vec<String>)], 339 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)],
326 status_filter: &str, 340 status_filter: &str,
327 label_filter: &HashSet<String>, 341 label_filter: &HashSet<String>,
328) { 342) {
@@ -332,16 +346,10 @@ fn output_table(
332 } 346 }
333 347
334 println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); 348 println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS");
335 for (proposal, status_kind, proposal_labels) in proposals { 349 for (proposal, status_kind, proposal_labels, subject_override) in proposals {
336 let id = proposal.id.to_string(); 350 let id = proposal.id.to_string();
337 let status = status_kind_to_str(*status_kind); 351 let status = status_kind_to_str(*status_kind);
338 let title = if let Ok(cl) = event_to_cover_letter(proposal) { 352 let title = proposal_title(proposal, subject_override.as_deref());
339 cl.title
340 } else if let Ok(msg) = tag_value(proposal, "description") {
341 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
342 } else {
343 proposal.id.to_string()
344 };
345 let labels_str: String = proposal_labels 353 let labels_str: String = proposal_labels
346 .iter() 354 .iter()
347 .map(|l| format!("#{l}")) 355 .map(|l| format!("#{l}"))
@@ -376,24 +384,26 @@ fn output_table(
376 ); 384 );
377} 385}
378 386
379fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { 387fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)]) -> Result<()> {
380 let json_output: Vec<serde_json::Value> = proposals 388 let json_output: Vec<serde_json::Value> = proposals
381 .iter() 389 .iter()
382 .map(|(proposal, status_kind, proposal_labels)| { 390 .map(|(proposal, status_kind, proposal_labels, subject_override)| {
383 let id = proposal.id.to_string(); 391 let id = proposal.id.to_string();
384 let status = status_kind_to_str(*status_kind).to_string(); 392 let status = status_kind_to_str(*status_kind).to_string();
385 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { 393 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) {
386 ( 394 (
387 cl.title.clone(), 395 subject_override.clone().unwrap_or(cl.title.clone()),
388 proposal.pubkey.to_bech32().unwrap_or_default(), 396 proposal.pubkey.to_bech32().unwrap_or_default(),
389 cl.get_branch_name_with_pr_prefix_and_shorthand_id() 397 cl.get_branch_name_with_pr_prefix_and_shorthand_id()
390 .unwrap_or_default(), 398 .unwrap_or_default(),
391 ) 399 )
392 } else { 400 } else {
393 let title = tag_value(proposal, "description").map_or_else( 401 let title = subject_override.clone().unwrap_or_else(|| {
394 |_| proposal.id.to_string(), 402 tag_value(proposal, "description").map_or_else(
395 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(), 403 |_| proposal.id.to_string(),
396 ); 404 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(),
405 )
406 });
397 ( 407 (
398 title, 408 title,
399 proposal.pubkey.to_bech32().unwrap_or_default(), 409 proposal.pubkey.to_bech32().unwrap_or_default(),
@@ -444,7 +454,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
444 454
445#[allow(clippy::too_many_lines)] 455#[allow(clippy::too_many_lines)]
446fn show_proposal_details( 456fn show_proposal_details(
447 proposals: &[(&nostr::Event, Kind, Vec<String>)], 457 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)],
448 event_id_or_nevent: &str, 458 event_id_or_nevent: &str,
449 json: bool, 459 json: bool,
450 show_comments: bool, 460 show_comments: bool,
@@ -455,14 +465,20 @@ fn show_proposal_details(
455 465
456 let target_id = resolve_event_id(event_id_or_nevent)?; 466 let target_id = resolve_event_id(event_id_or_nevent)?;
457 467
458 let (proposal, status_kind, proposal_labels) = proposals 468 let (proposal, status_kind, proposal_labels, subject_override) = proposals
459 .iter() 469 .iter()
460 .find(|(p, _, _)| p.id == target_id) 470 .find(|(p, _, _, _)| p.id == target_id)
461 .context("proposal not found")?; 471 .context("proposal not found")?;
462 472
463 let cover_letter = event_to_cover_letter(proposal) 473 let cover_letter = event_to_cover_letter(proposal)
464 .context("failed to extract proposal details from proposal root event")?; 474 .context("failed to extract proposal details from proposal root event")?;
465 475
476 // Use subject override if present, otherwise fall back to the original title.
477 let display_title = subject_override
478 .as_deref()
479 .unwrap_or(&cover_letter.title)
480 .to_string();
481
466 if json { 482 if json {
467 let json_output = if show_comments { 483 let json_output = if show_comments {
468 let comments_json: Vec<serde_json::Value> = comments 484 let comments_json: Vec<serde_json::Value> = comments
@@ -481,7 +497,7 @@ fn show_proposal_details(
481 serde_json::json!({ 497 serde_json::json!({
482 "id": proposal.id.to_string(), 498 "id": proposal.id.to_string(),
483 "status": status_kind_to_str(*status_kind), 499 "status": status_kind_to_str(*status_kind),
484 "title": cover_letter.title, 500 "title": display_title,
485 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 501 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
486 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 502 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
487 "labels": proposal_labels, 503 "labels": proposal_labels,
@@ -493,7 +509,7 @@ fn show_proposal_details(
493 serde_json::json!({ 509 serde_json::json!({
494 "id": proposal.id.to_string(), 510 "id": proposal.id.to_string(),
495 "status": status_kind_to_str(*status_kind), 511 "status": status_kind_to_str(*status_kind),
496 "title": cover_letter.title, 512 "title": display_title,
497 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 513 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
498 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 514 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
499 "labels": proposal_labels, 515 "labels": proposal_labels,
@@ -505,7 +521,7 @@ fn show_proposal_details(
505 return Ok(()); 521 return Ok(());
506 } 522 }
507 523
508 println!("Title: {}", cover_letter.title); 524 println!("Title: {display_title}");
509 println!( 525 println!(
510 "Author: {}", 526 "Author: {}",
511 proposal.pubkey.to_bech32().unwrap_or_default() 527 proposal.pubkey.to_bech32().unwrap_or_default()