diff options
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/ngit/cli.rs | 26 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 14 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 37 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 68 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/mod.rs | 1 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/set_subject.rs | 203 |
6 files changed, 307 insertions, 42 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index a240597..8cdbee1 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs | |||
| @@ -334,6 +334,19 @@ pub enum PrCommands { | |||
| 334 | #[arg(long)] | 334 | #[arg(long)] |
| 335 | offline: bool, | 335 | offline: bool, |
| 336 | }, | 336 | }, |
| 337 | /// set the subject/title of a PR (author or maintainer only) | ||
| 338 | #[command(name = "set-subject")] | ||
| 339 | SetSubject { | ||
| 340 | /// Proposal event-id (hex) or nevent (bech32) | ||
| 341 | #[arg(value_name = "ID|nevent")] | ||
| 342 | id: String, | ||
| 343 | /// New subject/title for the PR | ||
| 344 | #[arg(long, alias = "title")] | ||
| 345 | subject: String, | ||
| 346 | /// Use local cache only, skip network fetch | ||
| 347 | #[arg(long)] | ||
| 348 | offline: bool, | ||
| 349 | }, | ||
| 337 | } | 350 | } |
| 338 | 351 | ||
| 339 | // --------------------------------------------------------------------------- | 352 | // --------------------------------------------------------------------------- |
| @@ -461,6 +474,19 @@ pub enum IssueCommands { | |||
| 461 | #[arg(long)] | 474 | #[arg(long)] |
| 462 | offline: bool, | 475 | offline: bool, |
| 463 | }, | 476 | }, |
| 477 | /// set the subject/title of an issue (author or maintainer only) | ||
| 478 | #[command(name = "set-subject")] | ||
| 479 | SetSubject { | ||
| 480 | /// Issue event-id (hex) or nevent (bech32) | ||
| 481 | #[arg(value_name = "ID|nevent")] | ||
| 482 | id: String, | ||
| 483 | /// New subject/title for the issue | ||
| 484 | #[arg(long, alias = "title")] | ||
| 485 | subject: String, | ||
| 486 | /// Use local cache only, skip network fetch | ||
| 487 | #[arg(long)] | ||
| 488 | offline: bool, | ||
| 489 | }, | ||
| 464 | } | 490 | } |
| 465 | 491 | ||
| 466 | #[derive(Subcommand)] | 492 | #[derive(Subcommand)] |
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 3686011..1dbf020 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs | |||
| @@ -3,7 +3,7 @@ | |||
| 3 | #![cfg_attr(not(test), warn(clippy::expect_used))] | 3 | #![cfg_attr(not(test), warn(clippy::expect_used))] |
| 4 | 4 | ||
| 5 | use clap::Parser; | 5 | use clap::Parser; |
| 6 | use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; | 6 | use cli::{AccountCommands, Cli, Commands, IssueCommands, PrCommands, CUSTOMISE_TEMPLATE}; |
| 7 | 7 | ||
| 8 | mod cli; | 8 | mod cli; |
| 9 | use ngit::{ | 9 | use ngit::{ |
| @@ -138,6 +138,11 @@ async fn main() { | |||
| 138 | labels, | 138 | labels, |
| 139 | offline, | 139 | offline, |
| 140 | } => sub_commands::label::launch_pr_label(id, labels, *offline).await, | 140 | } => sub_commands::label::launch_pr_label(id, labels, *offline).await, |
| 141 | PrCommands::SetSubject { | ||
| 142 | id, | ||
| 143 | subject, | ||
| 144 | offline, | ||
| 145 | } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await, | ||
| 141 | }, | 146 | }, |
| 142 | Commands::Issue(args) => match &args.issue_command { | 147 | Commands::Issue(args) => match &args.issue_command { |
| 143 | IssueCommands::List { | 148 | IssueCommands::List { |
| @@ -210,6 +215,13 @@ async fn main() { | |||
| 210 | labels, | 215 | labels, |
| 211 | offline, | 216 | offline, |
| 212 | } => sub_commands::label::launch_issue_label(id, labels, *offline).await, | 217 | } => sub_commands::label::launch_issue_label(id, labels, *offline).await, |
| 218 | IssueCommands::SetSubject { | ||
| 219 | id, | ||
| 220 | subject, | ||
| 221 | offline, | ||
| 222 | } => { | ||
| 223 | sub_commands::set_subject::launch_issue_set_subject(id, subject, *offline).await | ||
| 224 | } | ||
| 213 | }, | 225 | }, |
| 214 | Commands::Sync(args) => sub_commands::sync::launch(args).await, | 226 | Commands::Sync(args) => sub_commands::sync::launch(args).await, |
| 215 | } | 227 | } |
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index 22b1b8a..8c7a7fc 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs | |||
| @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; | |||
| 3 | use anyhow::{Context, Result, bail}; | 3 | use anyhow::{Context, Result, bail}; |
| 4 | use ngit::{ | 4 | use ngit::{ |
| 5 | client::{Params, get_events_from_local_cache, get_issues_from_cache}, | 5 | client::{Params, get_events_from_local_cache, get_issues_from_cache}, |
| 6 | git_events::{KIND_COMMENT, KIND_LABEL, get_labels, get_status, status_kinds, tag_value}, | 6 | git_events::{KIND_COMMENT, KIND_LABEL, get_labels_and_subject, get_status, status_kinds, tag_value}, |
| 7 | }; | 7 | }; |
| 8 | use nostr::{ | 8 | use nostr::{ |
| 9 | FromBech32, ToBech32, | 9 | FromBech32, ToBech32, |
| @@ -18,7 +18,13 @@ use crate::{ | |||
| 18 | repo_ref::get_repo_coordinates_when_remote_unknown, | 18 | repo_ref::get_repo_coordinates_when_remote_unknown, |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | fn get_issue_title(event: &nostr::Event) -> String { | 21 | /// `(event, status_kind, labels, comment_count, subject_override)` |
| 22 | type IssueRow<'a> = (&'a nostr::Event, Kind, Vec<String>, usize, Option<String>); | ||
| 23 | |||
| 24 | fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> String { | ||
| 25 | if let Some(s) = subject_override { | ||
| 26 | return s.to_string(); | ||
| 27 | } | ||
| 22 | tag_value(event, "subject") | 28 | tag_value(event, "subject") |
| 23 | .ok() | 29 | .ok() |
| 24 | .filter(|s| !s.is_empty()) | 30 | .filter(|s| !s.is_empty()) |
| @@ -196,7 +202,7 @@ pub async fn launch( | |||
| 196 | // revisions, so we pass an empty slice. | 202 | // revisions, so we pass an empty slice. |
| 197 | let empty_proposals: Vec<nostr::Event> = vec![]; | 203 | let empty_proposals: Vec<nostr::Event> = vec![]; |
| 198 | 204 | ||
| 199 | let filtered: Vec<(&nostr::Event, Kind, Vec<String>, usize)> = issues | 205 | let filtered: Vec<IssueRow<'_>> = issues |
| 200 | .iter() | 206 | .iter() |
| 201 | .filter_map(|issue| { | 207 | .filter_map(|issue| { |
| 202 | let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); | 208 | let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); |
| @@ -204,7 +210,8 @@ pub async fn launch( | |||
| 204 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { | 210 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { |
| 205 | return None; | 211 | return None; |
| 206 | } | 212 | } |
| 207 | let issue_labels = get_labels(issue, &repo_ref, &label_events); | 213 | let (issue_labels, subject_override) = |
| 214 | get_labels_and_subject(issue, &repo_ref, &label_events); | ||
| 208 | if !label_filter.is_empty() { | 215 | if !label_filter.is_empty() { |
| 209 | let issue_labels_lower: HashSet<String> = | 216 | let issue_labels_lower: HashSet<String> = |
| 210 | issue_labels.iter().map(|t| t.to_lowercase()).collect(); | 217 | issue_labels.iter().map(|t| t.to_lowercase()).collect(); |
| @@ -213,7 +220,7 @@ pub async fn launch( | |||
| 213 | } | 220 | } |
| 214 | } | 221 | } |
| 215 | let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); | 222 | let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); |
| 216 | Some((issue, status_kind, issue_labels, comment_count)) | 223 | Some((issue, status_kind, issue_labels, comment_count, subject_override)) |
| 217 | }) | 224 | }) |
| 218 | .collect(); | 225 | .collect(); |
| 219 | 226 | ||
| @@ -286,7 +293,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> { | |||
| 286 | } | 293 | } |
| 287 | 294 | ||
| 288 | fn show_issue_details( | 295 | fn show_issue_details( |
| 289 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], | 296 | issues: &[IssueRow<'_>], |
| 290 | event_id_or_nevent: &str, | 297 | event_id_or_nevent: &str, |
| 291 | json: bool, | 298 | json: bool, |
| 292 | show_comments: bool, | 299 | show_comments: bool, |
| @@ -303,12 +310,12 @@ fn show_issue_details( | |||
| 303 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? | 310 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? |
| 304 | }; | 311 | }; |
| 305 | 312 | ||
| 306 | let (issue, status_kind, labels, comment_count) = issues | 313 | let (issue, status_kind, labels, comment_count, subject_override) = issues |
| 307 | .iter() | 314 | .iter() |
| 308 | .find(|(e, _, _, _)| e.id == target_id) | 315 | .find(|(e, _, _, _, _)| e.id == target_id) |
| 309 | .context("issue not found")?; | 316 | .context("issue not found")?; |
| 310 | 317 | ||
| 311 | let title = get_issue_title(issue); | 318 | let title = get_issue_title(issue, subject_override.as_deref()); |
| 312 | let status = status_kind_to_str(*status_kind); | 319 | let status = status_kind_to_str(*status_kind); |
| 313 | 320 | ||
| 314 | if json { | 321 | if json { |
| @@ -421,15 +428,15 @@ fn chrono_timestamp(unix_secs: u64) -> String { | |||
| 421 | } | 428 | } |
| 422 | 429 | ||
| 423 | fn output_table( | 430 | fn output_table( |
| 424 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], | 431 | issues: &[IssueRow<'_>], |
| 425 | status_filter: &str, | 432 | status_filter: &str, |
| 426 | label_filter: &HashSet<String>, | 433 | label_filter: &HashSet<String>, |
| 427 | ) { | 434 | ) { |
| 428 | println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); | 435 | println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); |
| 429 | for (issue, status_kind, labels, comment_count) in issues { | 436 | for (issue, status_kind, labels, comment_count, subject_override) in issues { |
| 430 | let id = issue.id.to_string(); | 437 | let id = issue.id.to_string(); |
| 431 | let status = status_kind_to_str(*status_kind); | 438 | let status = status_kind_to_str(*status_kind); |
| 432 | let title = get_issue_title(issue); | 439 | let title = get_issue_title(issue, subject_override.as_deref()); |
| 433 | let labels_str = if labels.is_empty() { | 440 | let labels_str = if labels.is_empty() { |
| 434 | String::new() | 441 | String::new() |
| 435 | } else { | 442 | } else { |
| @@ -456,14 +463,14 @@ fn output_table( | |||
| 456 | println!(); | 463 | println!(); |
| 457 | } | 464 | } |
| 458 | 465 | ||
| 459 | fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>, usize)]) -> Result<()> { | 466 | fn output_json(issues: &[IssueRow<'_>]) -> Result<()> { |
| 460 | let json_output: Vec<serde_json::Value> = issues | 467 | let json_output: Vec<serde_json::Value> = issues |
| 461 | .iter() | 468 | .iter() |
| 462 | .map(|(issue, status_kind, labels, comment_count)| { | 469 | .map(|(issue, status_kind, labels, comment_count, subject_override)| { |
| 463 | serde_json::json!({ | 470 | serde_json::json!({ |
| 464 | "id": issue.id.to_string(), | 471 | "id": issue.id.to_string(), |
| 465 | "status": status_kind_to_str(*status_kind), | 472 | "status": status_kind_to_str(*status_kind), |
| 466 | "title": get_issue_title(issue), | 473 | "title": get_issue_title(issue, subject_override.as_deref()), |
| 467 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | 474 | "author": issue.pubkey.to_bech32().unwrap_or_default(), |
| 468 | "labels": labels, | 475 | "labels": labels, |
| 469 | "comment_count": comment_count, | 476 | "comment_count": comment_count, |
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 | ||
| 325 | fn 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 | |||
| 324 | fn output_table( | 338 | fn 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 | ||
| 379 | fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { | 387 | fn 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)] |
| 446 | fn show_proposal_details( | 456 | fn 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() |
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index 7a3c2b5..db8ea54 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs | |||
| @@ -15,5 +15,6 @@ pub mod pr_merge; | |||
| 15 | pub mod pr_status; | 15 | pub mod pr_status; |
| 16 | pub mod repo; | 16 | pub mod repo; |
| 17 | pub mod send; | 17 | pub mod send; |
| 18 | pub mod set_subject; | ||
| 18 | pub mod sync; | 19 | pub mod sync; |
| 19 | pub mod whoami; | 20 | pub mod whoami; |
diff --git a/src/bin/ngit/sub_commands/set_subject.rs b/src/bin/ngit/sub_commands/set_subject.rs new file mode 100644 index 0000000..65ff1d3 --- /dev/null +++ b/src/bin/ngit/sub_commands/set_subject.rs | |||
| @@ -0,0 +1,203 @@ | |||
| 1 | use anyhow::{Context, Result, bail}; | ||
| 2 | use ngit::{ | ||
| 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, | ||
| 4 | git_events::{KIND_LABEL, get_labels_and_subject}, | ||
| 5 | }; | ||
| 6 | use nostr::{EventBuilder, Tag, TagStandard}; | ||
| 7 | use nostr_sdk::{EventId, FromBech32}; | ||
| 8 | use nostr::nips::nip19::Nip19; | ||
| 9 | |||
| 10 | use crate::{ | ||
| 11 | client::{ | ||
| 12 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | ||
| 13 | save_event_in_local_cache, | ||
| 14 | }, | ||
| 15 | git::{Repo, RepoActions}, | ||
| 16 | login, | ||
| 17 | repo_ref::get_repo_coordinates_when_remote_unknown, | ||
| 18 | }; | ||
| 19 | |||
| 20 | fn parse_event_id(id: &str) -> Result<EventId> { | ||
| 21 | if let Ok(nip19) = Nip19::from_bech32(id) { | ||
| 22 | match nip19 { | ||
| 23 | Nip19::Event(e) => return Ok(e.event_id), | ||
| 24 | Nip19::EventId(event_id) => return Ok(event_id), | ||
| 25 | _ => {} | ||
| 26 | } | ||
| 27 | } | ||
| 28 | if let Ok(event_id) = EventId::from_hex(id) { | ||
| 29 | return Ok(event_id); | ||
| 30 | } | ||
| 31 | bail!("invalid event-id or nevent: {id}") | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Shared implementation: publish a NIP-32 kind-1985 `#subject` label event | ||
| 35 | /// for `target`, overriding its displayed title/subject. | ||
| 36 | /// | ||
| 37 | /// Only the author of the target event or a repository maintainer may set the | ||
| 38 | /// subject. The subject is not applied to the underlying git commit message — | ||
| 39 | /// it only affects how the PR/issue title is displayed. | ||
| 40 | #[allow(clippy::too_many_lines)] | ||
| 41 | async fn publish_set_subject_event( | ||
| 42 | id: &str, | ||
| 43 | subject: &str, | ||
| 44 | offline: bool, | ||
| 45 | target_kind: &str, // "issue" or "PR" — used in error messages | ||
| 46 | ) -> Result<()> { | ||
| 47 | let subject = subject.trim(); | ||
| 48 | if subject.is_empty() { | ||
| 49 | bail!("--subject value must not be empty"); | ||
| 50 | } | ||
| 51 | |||
| 52 | let event_id = parse_event_id(id)?; | ||
| 53 | |||
| 54 | let git_repo = Repo::discover().context("failed to find a git repository")?; | ||
| 55 | let git_repo_path = git_repo.get_path()?; | ||
| 56 | |||
| 57 | let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | ||
| 58 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; | ||
| 59 | |||
| 60 | if !offline { | ||
| 61 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 62 | } | ||
| 63 | |||
| 64 | let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; | ||
| 65 | |||
| 66 | // Resolve the target event from cache. | ||
| 67 | let target = if target_kind == "issue" { | ||
| 68 | let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 69 | issues | ||
| 70 | .into_iter() | ||
| 71 | .find(|e| e.id == event_id) | ||
| 72 | .context(format!( | ||
| 73 | "issue with id {} not found in cache", | ||
| 74 | event_id.to_hex() | ||
| 75 | ))? | ||
| 76 | } else { | ||
| 77 | let proposals = | ||
| 78 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 79 | proposals | ||
| 80 | .into_iter() | ||
| 81 | .find(|e| e.id == event_id) | ||
| 82 | .context(format!( | ||
| 83 | "PR with id {} not found in cache", | ||
| 84 | event_id.to_hex() | ||
| 85 | ))? | ||
| 86 | }; | ||
| 87 | |||
| 88 | // Login — we need the signer and user pubkey. | ||
| 89 | let (signer, user_ref, _) = | ||
| 90 | login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; | ||
| 91 | |||
| 92 | let user_pubkey = signer.get_public_key().await?; | ||
| 93 | |||
| 94 | // Permission check: only the author or a maintainer may set the subject. | ||
| 95 | if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { | ||
| 96 | bail!( | ||
| 97 | "only the {target_kind} author or a repository maintainer can set the subject of a {target_kind}" | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | // Fetch existing label events so we can check whether the subject is | ||
| 102 | // already set to the requested value. | ||
| 103 | let existing_label_events = get_events_from_local_cache( | ||
| 104 | git_repo_path, | ||
| 105 | vec![ | ||
| 106 | nostr::Filter::default() | ||
| 107 | .event(event_id) | ||
| 108 | .kind(KIND_LABEL), | ||
| 109 | ], | ||
| 110 | ) | ||
| 111 | .await?; | ||
| 112 | |||
| 113 | let (_, existing_subject) = get_labels_and_subject(&target, &repo_ref, &existing_label_events); | ||
| 114 | |||
| 115 | if existing_subject.as_deref() == Some(subject) { | ||
| 116 | println!( | ||
| 117 | "{target_kind} {} already has subject: {}", | ||
| 118 | &event_id.to_hex()[..8], | ||
| 119 | subject, | ||
| 120 | ); | ||
| 121 | return Ok(()); | ||
| 122 | } | ||
| 123 | |||
| 124 | // Build the kind-1985 subject label event. | ||
| 125 | // | ||
| 126 | // Structure (NIP-32 §subject namespace): | ||
| 127 | // ["L", "#subject"] — namespace declaration | ||
| 128 | // ["l", "<new title>", "#subject"] — the new subject value | ||
| 129 | // ["e", <target-id>, <relay>] — reference to the labelled event | ||
| 130 | // ["p", <author-pubkey>] — notify the author | ||
| 131 | let relay_hint = repo_ref.relays.first().cloned(); | ||
| 132 | |||
| 133 | let mut tags: Vec<Tag> = vec![ | ||
| 134 | // Namespace declaration | ||
| 135 | Tag::custom( | ||
| 136 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")), | ||
| 137 | vec!["#subject".to_string()], | ||
| 138 | ), | ||
| 139 | // Subject value | ||
| 140 | Tag::custom( | ||
| 141 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")), | ||
| 142 | vec![subject.to_string(), "#subject".to_string()], | ||
| 143 | ), | ||
| 144 | ]; | ||
| 145 | |||
| 146 | // Reference the target event. | ||
| 147 | tags.push(Tag::from_standardized(TagStandard::Event { | ||
| 148 | event_id: target.id, | ||
| 149 | relay_url: relay_hint.clone(), | ||
| 150 | marker: None, | ||
| 151 | public_key: None, | ||
| 152 | uppercase: false, | ||
| 153 | })); | ||
| 154 | |||
| 155 | // Notify the target event author. | ||
| 156 | tags.push(Tag::public_key(target.pubkey)); | ||
| 157 | |||
| 158 | // Human-readable alt text. | ||
| 159 | tags.push(Tag::custom( | ||
| 160 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 161 | vec![format!("set {target_kind} subject to: {subject}")], | ||
| 162 | )); | ||
| 163 | |||
| 164 | let subject_event = ngit::client::sign_event( | ||
| 165 | EventBuilder::new(KIND_LABEL, "").tags(tags), | ||
| 166 | &signer, | ||
| 167 | format!("set {target_kind} subject"), | ||
| 168 | ) | ||
| 169 | .await?; | ||
| 170 | |||
| 171 | // Save to local cache immediately so subsequent reads reflect the new subject. | ||
| 172 | save_event_in_local_cache(git_repo_path, &subject_event).await?; | ||
| 173 | |||
| 174 | let mut client = client; | ||
| 175 | client.set_signer(signer).await; | ||
| 176 | |||
| 177 | send_events( | ||
| 178 | &client, | ||
| 179 | Some(git_repo_path), | ||
| 180 | vec![subject_event], | ||
| 181 | user_ref.relays.write(), | ||
| 182 | repo_ref.relays.clone(), | ||
| 183 | true, | ||
| 184 | false, | ||
| 185 | ) | ||
| 186 | .await?; | ||
| 187 | |||
| 188 | println!( | ||
| 189 | "{} {} subject set to: {}", | ||
| 190 | target_kind, | ||
| 191 | &event_id.to_hex()[..8], | ||
| 192 | subject, | ||
| 193 | ); | ||
| 194 | Ok(()) | ||
| 195 | } | ||
| 196 | |||
| 197 | pub async fn launch_issue_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> { | ||
| 198 | publish_set_subject_event(id, subject, offline, "issue").await | ||
| 199 | } | ||
| 200 | |||
| 201 | pub async fn launch_pr_set_subject(id: &str, subject: &str, offline: bool) -> Result<()> { | ||
| 202 | publish_set_subject_event(id, subject, offline, "PR").await | ||
| 203 | } | ||