diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 14:19:49 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 14:23:07 +0000 |
| commit | 37244449d6d0d58bb639f181bd15092de1acaaee (patch) | |
| tree | 7de03867a1a9578e32fdbdbb2be63e863cea57a4 /src | |
| parent | 609f3c3db02d437222e2c8e171189179d06c3e9c (diff) | |
feat(cover-note): add kind-1624 cover notes for PRs, patches, and issues
Implements experimental kind-1624 cover note events:
- KIND_COVER_NOTE constant and process_cover_note() in git_events.rs;
replaceable semantics (latest created_at, hex-id tiebreak), author or
maintainer only
- kind-1624 events fetched alongside labels in the fetch pipeline;
cover_notes count added to FetchReport display
- ngit pr/issue view: cover note displayed in place of description with
a clear 'Cover Note:' header; maintainer-authored notes identify the
author; original description shown only with --comments; cover_note
object included in --json output
- ngit pr set-cover-note / ngit issue set-cover-note: publish a
kind-1624 event; nostr: mentions in --body converted to q/p tags via
tags_from_content (same rules as issue --body)
- Fix pre-existing clippy::too_many_lines on repo/mod.rs show_info
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/ngit/cli.rs | 35 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 75 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 139 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/label.rs | 22 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 174 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/mod.rs | 1 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/pr_status.rs | 9 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/repo/mod.rs | 71 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/set_cover_note.rs | 202 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/set_subject.rs | 13 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/whoami.rs | 9 | ||||
| -rw-r--r-- | src/lib/client.rs | 34 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 67 |
13 files changed, 654 insertions, 197 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 8660b60..d558525 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs | |||
| @@ -160,7 +160,8 @@ pub struct RepoSubCommandArgs { | |||
| 160 | /// Use local cache only, skip network fetch | 160 | /// Use local cache only, skip network fetch |
| 161 | #[arg(long)] | 161 | #[arg(long)] |
| 162 | pub offline: bool, | 162 | pub offline: bool, |
| 163 | /// Output repository info as JSON; `is_nostr_repo` is false when not in a nostr repository | 163 | /// Output repository info as JSON; `is_nostr_repo` is false when not in a |
| 164 | /// nostr repository | ||
| 164 | #[arg(long)] | 165 | #[arg(long)] |
| 165 | pub json: bool, | 166 | pub json: bool, |
| 166 | } | 167 | } |
| @@ -347,6 +348,22 @@ pub enum PrCommands { | |||
| 347 | #[arg(long)] | 348 | #[arg(long)] |
| 348 | offline: bool, | 349 | offline: bool, |
| 349 | }, | 350 | }, |
| 351 | /// set or update the cover note for a PR (author or maintainer only) | ||
| 352 | /// | ||
| 353 | /// A cover note is a markdown body that replaces the displayed description. | ||
| 354 | /// nostr: mentions in --body are converted to q/p tags automatically. | ||
| 355 | #[command(name = "set-cover-note")] | ||
| 356 | SetCoverNote { | ||
| 357 | /// Proposal event-id (hex) or nevent (bech32) | ||
| 358 | #[arg(value_name = "ID|nevent")] | ||
| 359 | id: String, | ||
| 360 | /// Markdown body for the cover note | ||
| 361 | #[arg(long)] | ||
| 362 | body: String, | ||
| 363 | /// Use local cache only, skip network fetch | ||
| 364 | #[arg(long)] | ||
| 365 | offline: bool, | ||
| 366 | }, | ||
| 350 | } | 367 | } |
| 351 | 368 | ||
| 352 | // --------------------------------------------------------------------------- | 369 | // --------------------------------------------------------------------------- |
| @@ -487,6 +504,22 @@ pub enum IssueCommands { | |||
| 487 | #[arg(long)] | 504 | #[arg(long)] |
| 488 | offline: bool, | 505 | offline: bool, |
| 489 | }, | 506 | }, |
| 507 | /// set or update the cover note for an issue (author or maintainer only) | ||
| 508 | /// | ||
| 509 | /// A cover note is a markdown body that replaces the displayed description. | ||
| 510 | /// nostr: mentions in --body are converted to q/p tags automatically. | ||
| 511 | #[command(name = "set-cover-note")] | ||
| 512 | SetCoverNote { | ||
| 513 | /// Issue event-id (hex) or nevent (bech32) | ||
| 514 | #[arg(value_name = "ID|nevent")] | ||
| 515 | id: String, | ||
| 516 | /// Markdown body for the cover note | ||
| 517 | #[arg(long)] | ||
| 518 | body: String, | ||
| 519 | /// Use local cache only, skip network fetch | ||
| 520 | #[arg(long)] | ||
| 521 | offline: bool, | ||
| 522 | }, | ||
| 490 | } | 523 | } |
| 491 | 524 | ||
| 492 | #[derive(Subcommand)] | 525 | #[derive(Subcommand)] |
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 3f5e65c..a656412 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, Cli, Commands, IssueCommands, PrCommands, CUSTOMISE_TEMPLATE}; | 6 | use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; |
| 7 | 7 | ||
| 8 | mod cli; | 8 | mod cli; |
| 9 | use ngit::{ | 9 | use ngit::{ |
| @@ -54,7 +54,13 @@ async fn main() { | |||
| 54 | }, | 54 | }, |
| 55 | Commands::Init(args) => sub_commands::init::launch(&cli, args).await, | 55 | Commands::Init(args) => sub_commands::init::launch(&cli, args).await, |
| 56 | Commands::Repo(args) => { | 56 | Commands::Repo(args) => { |
| 57 | sub_commands::repo::launch(&cli, args.repo_command.as_ref(), args.offline, args.json).await | 57 | sub_commands::repo::launch( |
| 58 | &cli, | ||
| 59 | args.repo_command.as_ref(), | ||
| 60 | args.offline, | ||
| 61 | args.json, | ||
| 62 | ) | ||
| 63 | .await | ||
| 58 | } | 64 | } |
| 59 | Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, | 65 | Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, |
| 60 | Commands::Pr(args) => match &args.pr_command { | 66 | Commands::Pr(args) => match &args.pr_command { |
| @@ -102,18 +108,26 @@ async fn main() { | |||
| 102 | PrCommands::Send(sub_args) => { | 108 | PrCommands::Send(sub_args) => { |
| 103 | sub_commands::send::launch(&cli, sub_args, false).await | 109 | sub_commands::send::launch(&cli, sub_args, false).await |
| 104 | } | 110 | } |
| 105 | PrCommands::Close { id, reason, offline } => { | 111 | PrCommands::Close { |
| 106 | sub_commands::pr_status::launch_close(id, *offline, reason.as_deref()).await | 112 | id, |
| 107 | } | 113 | reason, |
| 108 | PrCommands::Reopen { id, reason, offline } => { | 114 | offline, |
| 109 | sub_commands::pr_status::launch_reopen(id, *offline, reason.as_deref()).await | 115 | } => sub_commands::pr_status::launch_close(id, *offline, reason.as_deref()).await, |
| 110 | } | 116 | PrCommands::Reopen { |
| 111 | PrCommands::Ready { id, reason, offline } => { | 117 | id, |
| 112 | sub_commands::pr_status::launch_ready(id, *offline, reason.as_deref()).await | 118 | reason, |
| 113 | } | 119 | offline, |
| 114 | PrCommands::Draft { id, reason, offline } => { | 120 | } => sub_commands::pr_status::launch_reopen(id, *offline, reason.as_deref()).await, |
| 115 | sub_commands::pr_status::launch_draft(id, *offline, reason.as_deref()).await | 121 | PrCommands::Ready { |
| 116 | } | 122 | id, |
| 123 | reason, | ||
| 124 | offline, | ||
| 125 | } => sub_commands::pr_status::launch_ready(id, *offline, reason.as_deref()).await, | ||
| 126 | PrCommands::Draft { | ||
| 127 | id, | ||
| 128 | reason, | ||
| 129 | offline, | ||
| 130 | } => sub_commands::pr_status::launch_draft(id, *offline, reason.as_deref()).await, | ||
| 117 | PrCommands::Comment { | 131 | PrCommands::Comment { |
| 118 | id, | 132 | id, |
| 119 | body, | 133 | body, |
| @@ -143,6 +157,9 @@ async fn main() { | |||
| 143 | subject, | 157 | subject, |
| 144 | offline, | 158 | offline, |
| 145 | } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await, | 159 | } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await, |
| 160 | PrCommands::SetCoverNote { id, body, offline } => { | ||
| 161 | sub_commands::set_cover_note::launch_pr_set_cover_note(id, body, *offline).await | ||
| 162 | } | ||
| 146 | }, | 163 | }, |
| 147 | Commands::Issue(args) => match &args.issue_command { | 164 | Commands::Issue(args) => match &args.issue_command { |
| 148 | IssueCommands::List { | 165 | IssueCommands::List { |
| @@ -183,17 +200,33 @@ async fn main() { | |||
| 183 | body, | 200 | body, |
| 184 | labels, | 201 | labels, |
| 185 | } => { | 202 | } => { |
| 186 | sub_commands::issue_create::launch(subject.clone(), body.clone(), labels.clone()) | 203 | sub_commands::issue_create::launch( |
| 187 | .await | 204 | subject.clone(), |
| 205 | body.clone(), | ||
| 206 | labels.clone(), | ||
| 207 | ) | ||
| 208 | .await | ||
| 188 | } | 209 | } |
| 189 | IssueCommands::Close { id, reason, offline } => { | 210 | IssueCommands::Close { |
| 211 | id, | ||
| 212 | reason, | ||
| 213 | offline, | ||
| 214 | } => { | ||
| 190 | sub_commands::issue_status::launch_close(id, *offline, reason.as_deref()).await | 215 | sub_commands::issue_status::launch_close(id, *offline, reason.as_deref()).await |
| 191 | } | 216 | } |
| 192 | IssueCommands::Resolved { id, reason, offline } => { | 217 | IssueCommands::Resolved { |
| 218 | id, | ||
| 219 | reason, | ||
| 220 | offline, | ||
| 221 | } => { | ||
| 193 | sub_commands::issue_status::launch_resolved(id, *offline, reason.as_deref()) | 222 | sub_commands::issue_status::launch_resolved(id, *offline, reason.as_deref()) |
| 194 | .await | 223 | .await |
| 195 | } | 224 | } |
| 196 | IssueCommands::Reopen { id, reason, offline } => { | 225 | IssueCommands::Reopen { |
| 226 | id, | ||
| 227 | reason, | ||
| 228 | offline, | ||
| 229 | } => { | ||
| 197 | sub_commands::issue_status::launch_reopen(id, *offline, reason.as_deref()).await | 230 | sub_commands::issue_status::launch_reopen(id, *offline, reason.as_deref()).await |
| 198 | } | 231 | } |
| 199 | IssueCommands::Comment { | 232 | IssueCommands::Comment { |
| @@ -222,6 +255,10 @@ async fn main() { | |||
| 222 | } => { | 255 | } => { |
| 223 | sub_commands::set_subject::launch_issue_set_subject(id, subject, *offline).await | 256 | sub_commands::set_subject::launch_issue_set_subject(id, subject, *offline).await |
| 224 | } | 257 | } |
| 258 | IssueCommands::SetCoverNote { id, body, offline } => { | ||
| 259 | sub_commands::set_cover_note::launch_issue_set_cover_note(id, body, *offline) | ||
| 260 | .await | ||
| 261 | } | ||
| 225 | }, | 262 | }, |
| 226 | Commands::Sync(args) => sub_commands::sync::launch(args).await, | 263 | Commands::Sync(args) => sub_commands::sync::launch(args).await, |
| 227 | } | 264 | } |
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index 29845ce..9402ac0 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs | |||
| @@ -3,7 +3,10 @@ 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_and_subject, get_status, status_kinds, tag_value}, | 6 | git_events::{ |
| 7 | KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, get_labels_and_subject, get_status, | ||
| 8 | process_cover_note, status_kinds, tag_value, | ||
| 9 | }, | ||
| 7 | }; | 10 | }; |
| 8 | use nostr::{ | 11 | use nostr::{ |
| 9 | FromBech32, ToBech32, | 12 | FromBech32, ToBech32, |
| @@ -44,8 +47,6 @@ fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> Stri | |||
| 44 | }) | 47 | }) |
| 45 | } | 48 | } |
| 46 | 49 | ||
| 47 | |||
| 48 | |||
| 49 | fn status_kind_to_str(kind: Kind) -> &'static str { | 50 | fn status_kind_to_str(kind: Kind) -> &'static str { |
| 50 | match kind { | 51 | match kind { |
| 51 | Kind::GitStatusOpen => "open", | 52 | Kind::GitStatusOpen => "open", |
| @@ -220,7 +221,13 @@ pub async fn launch( | |||
| 220 | } | 221 | } |
| 221 | } | 222 | } |
| 222 | let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); | 223 | let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); |
| 223 | Some((issue, status_kind, issue_labels, comment_count, subject_override)) | 224 | Some(( |
| 225 | issue, | ||
| 226 | status_kind, | ||
| 227 | issue_labels, | ||
| 228 | comment_count, | ||
| 229 | subject_override, | ||
| 230 | )) | ||
| 224 | }) | 231 | }) |
| 225 | .collect(); | 232 | .collect(); |
| 226 | 233 | ||
| @@ -247,6 +254,16 @@ pub async fn launch( | |||
| 247 | } else { | 254 | } else { |
| 248 | vec![] | 255 | vec![] |
| 249 | }; | 256 | }; |
| 257 | // Fetch kind-1624 cover note events for this issue. | ||
| 258 | let cover_note_events = get_events_from_local_cache( | ||
| 259 | git_repo_path, | ||
| 260 | vec![ | ||
| 261 | nostr::Filter::default() | ||
| 262 | .event(target_id) | ||
| 263 | .kind(KIND_COVER_NOTE), | ||
| 264 | ], | ||
| 265 | ) | ||
| 266 | .await?; | ||
| 250 | let relay_hint = repo_ref.relays.first(); | 267 | let relay_hint = repo_ref.relays.first(); |
| 251 | return show_issue_details( | 268 | return show_issue_details( |
| 252 | &filtered, | 269 | &filtered, |
| @@ -254,6 +271,8 @@ pub async fn launch( | |||
| 254 | json, | 271 | json, |
| 255 | show_comments, | 272 | show_comments, |
| 256 | &comments, | 273 | &comments, |
| 274 | &cover_note_events, | ||
| 275 | &repo_ref, | ||
| 257 | relay_hint, | 276 | relay_hint, |
| 258 | ); | 277 | ); |
| 259 | } | 278 | } |
| @@ -295,12 +314,15 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> { | |||
| 295 | }) | 314 | }) |
| 296 | } | 315 | } |
| 297 | 316 | ||
| 317 | #[allow(clippy::too_many_arguments, clippy::too_many_lines)] | ||
| 298 | fn show_issue_details( | 318 | fn show_issue_details( |
| 299 | issues: &[IssueRow<'_>], | 319 | issues: &[IssueRow<'_>], |
| 300 | event_id_or_nevent: &str, | 320 | event_id_or_nevent: &str, |
| 301 | json: bool, | 321 | json: bool, |
| 302 | show_comments: bool, | 322 | show_comments: bool, |
| 303 | comments: &[nostr::Event], | 323 | comments: &[nostr::Event], |
| 324 | cover_note_events: &[nostr::Event], | ||
| 325 | repo_ref: &ngit::repo_ref::RepoRef, | ||
| 304 | relay_hint: Option<&RelayUrl>, | 326 | relay_hint: Option<&RelayUrl>, |
| 305 | ) -> Result<()> { | 327 | ) -> Result<()> { |
| 306 | let target_id = if event_id_or_nevent.starts_with("nevent") { | 328 | let target_id = if event_id_or_nevent.starts_with("nevent") { |
| @@ -322,13 +344,40 @@ fn show_issue_details( | |||
| 322 | let title = get_issue_title(issue, subject_override.as_deref()); | 344 | let title = get_issue_title(issue, subject_override.as_deref()); |
| 323 | let status = status_kind_to_str(*status_kind); | 345 | let status = status_kind_to_str(*status_kind); |
| 324 | 346 | ||
| 347 | // Resolve the effective cover note (kind 1624) for this issue. | ||
| 348 | let cover_note = process_cover_note(issue, repo_ref, cover_note_events); | ||
| 349 | |||
| 325 | if json { | 350 | if json { |
| 326 | let json_output = if show_comments { | 351 | let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| { |
| 352 | let mut obj = serde_json::json!({ | ||
| 353 | "id": event_id_to_nevent(cn.id, relay_hint), | ||
| 354 | "author": cn.pubkey.to_bech32().unwrap_or_default(), | ||
| 355 | "created_at": cn.created_at.as_secs(), | ||
| 356 | "body": cn.content, | ||
| 357 | }); | ||
| 358 | if *by_different_author { | ||
| 359 | obj["by_maintainer"] = serde_json::Value::Bool(true); | ||
| 360 | } | ||
| 361 | obj | ||
| 362 | }); | ||
| 363 | |||
| 364 | let mut json_obj = serde_json::json!({ | ||
| 365 | "id": event_id_to_nevent(issue.id, relay_hint), | ||
| 366 | "status": status, | ||
| 367 | "subject": title, | ||
| 368 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | ||
| 369 | "labels": labels, | ||
| 370 | "comment_count": comment_count, | ||
| 371 | "description": issue.content, | ||
| 372 | }); | ||
| 373 | if let Some(cn) = cover_note_json { | ||
| 374 | json_obj["cover_note"] = cn; | ||
| 375 | } | ||
| 376 | if show_comments { | ||
| 327 | let comments_json: Vec<serde_json::Value> = comments | 377 | let comments_json: Vec<serde_json::Value> = comments |
| 328 | .iter() | 378 | .iter() |
| 329 | .map(|c| { | 379 | .map(|c| { |
| 330 | let reply_to = | 380 | let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); |
| 331 | comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); | ||
| 332 | serde_json::json!({ | 381 | serde_json::json!({ |
| 333 | "id": event_id_to_nevent(c.id, relay_hint), | 382 | "id": event_id_to_nevent(c.id, relay_hint), |
| 334 | "author": c.pubkey.to_bech32().unwrap_or_default(), | 383 | "author": c.pubkey.to_bech32().unwrap_or_default(), |
| @@ -338,28 +387,9 @@ fn show_issue_details( | |||
| 338 | }) | 387 | }) |
| 339 | }) | 388 | }) |
| 340 | .collect(); | 389 | .collect(); |
| 341 | serde_json::json!({ | 390 | json_obj["comments"] = serde_json::Value::Array(comments_json); |
| 342 | "id": event_id_to_nevent(issue.id, relay_hint), | 391 | } |
| 343 | "status": status, | 392 | println!("{}", serde_json::to_string_pretty(&json_obj)?); |
| 344 | "subject": title, | ||
| 345 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | ||
| 346 | "labels": labels, | ||
| 347 | "comment_count": comment_count, | ||
| 348 | "comments": comments_json, | ||
| 349 | "description": issue.content, | ||
| 350 | }) | ||
| 351 | } else { | ||
| 352 | serde_json::json!({ | ||
| 353 | "id": event_id_to_nevent(issue.id, relay_hint), | ||
| 354 | "status": status, | ||
| 355 | "subject": title, | ||
| 356 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | ||
| 357 | "labels": labels, | ||
| 358 | "comment_count": comment_count, | ||
| 359 | "description": issue.content, | ||
| 360 | }) | ||
| 361 | }; | ||
| 362 | println!("{}", serde_json::to_string_pretty(&json_output)?); | ||
| 363 | return Ok(()); | 393 | return Ok(()); |
| 364 | } | 394 | } |
| 365 | 395 | ||
| @@ -375,7 +405,28 @@ fn show_issue_details( | |||
| 375 | println!("Labels: {labels_str}"); | 405 | println!("Labels: {labels_str}"); |
| 376 | } | 406 | } |
| 377 | 407 | ||
| 378 | if !issue.content.is_empty() { | 408 | if let Some((cn, by_different_author)) = &cover_note { |
| 409 | println!(); | ||
| 410 | if *by_different_author { | ||
| 411 | println!( | ||
| 412 | "Cover Note (by {}):", | ||
| 413 | cn.pubkey.to_bech32().unwrap_or_default() | ||
| 414 | ); | ||
| 415 | } else { | ||
| 416 | println!("Cover Note:"); | ||
| 417 | } | ||
| 418 | for line in cn.content.lines() { | ||
| 419 | println!(" {line}"); | ||
| 420 | } | ||
| 421 | // Show original description only when --comments is used. | ||
| 422 | if show_comments && !issue.content.is_empty() { | ||
| 423 | println!(); | ||
| 424 | println!("Original Description:"); | ||
| 425 | for line in issue.content.lines() { | ||
| 426 | println!(" {line}"); | ||
| 427 | } | ||
| 428 | } | ||
| 429 | } else if !issue.content.is_empty() { | ||
| 379 | println!(); | 430 | println!(); |
| 380 | for line in issue.content.lines() { | 431 | for line in issue.content.lines() { |
| 381 | println!(" {line}"); | 432 | println!(" {line}"); |
| @@ -432,11 +483,7 @@ fn chrono_timestamp(unix_secs: u64) -> String { | |||
| 432 | format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") | 483 | format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") |
| 433 | } | 484 | } |
| 434 | 485 | ||
| 435 | fn output_table( | 486 | fn output_table(issues: &[IssueRow<'_>], status_filter: &str, label_filter: &HashSet<String>) { |
| 436 | issues: &[IssueRow<'_>], | ||
| 437 | status_filter: &str, | ||
| 438 | label_filter: &HashSet<String>, | ||
| 439 | ) { | ||
| 440 | println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); | 487 | println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); |
| 441 | for (issue, status_kind, labels, comment_count, subject_override) in issues { | 488 | for (issue, status_kind, labels, comment_count, subject_override) in issues { |
| 442 | let id = issue.id.to_string(); | 489 | let id = issue.id.to_string(); |
| @@ -485,16 +532,18 @@ fn event_id_to_nevent(event_id: nostr::EventId, relay: Option<&RelayUrl>) -> Str | |||
| 485 | fn output_json(issues: &[IssueRow<'_>], relay_hint: Option<&RelayUrl>) -> Result<()> { | 532 | fn output_json(issues: &[IssueRow<'_>], relay_hint: Option<&RelayUrl>) -> Result<()> { |
| 486 | let json_output: Vec<serde_json::Value> = issues | 533 | let json_output: Vec<serde_json::Value> = issues |
| 487 | .iter() | 534 | .iter() |
| 488 | .map(|(issue, status_kind, labels, comment_count, subject_override)| { | 535 | .map( |
| 489 | serde_json::json!({ | 536 | |(issue, status_kind, labels, comment_count, subject_override)| { |
| 490 | "id": event_id_to_nevent(issue.id, relay_hint), | 537 | serde_json::json!({ |
| 491 | "status": status_kind_to_str(*status_kind), | 538 | "id": event_id_to_nevent(issue.id, relay_hint), |
| 492 | "subject": get_issue_title(issue, subject_override.as_deref()), | 539 | "status": status_kind_to_str(*status_kind), |
| 493 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | 540 | "subject": get_issue_title(issue, subject_override.as_deref()), |
| 494 | "labels": labels, | 541 | "author": issue.pubkey.to_bech32().unwrap_or_default(), |
| 495 | "comment_count": comment_count, | 542 | "labels": labels, |
| 496 | }) | 543 | "comment_count": comment_count, |
| 497 | }) | 544 | }) |
| 545 | }, | ||
| 546 | ) | ||
| 498 | .collect(); | 547 | .collect(); |
| 499 | println!("{}", serde_json::to_string_pretty(&json_output)?); | 548 | println!("{}", serde_json::to_string_pretty(&json_output)?); |
| 500 | Ok(()) | 549 | Ok(()) |
diff --git a/src/bin/ngit/sub_commands/label.rs b/src/bin/ngit/sub_commands/label.rs index 634e0b5..f6714ae 100644 --- a/src/bin/ngit/sub_commands/label.rs +++ b/src/bin/ngit/sub_commands/label.rs | |||
| @@ -3,14 +3,13 @@ use ngit::{ | |||
| 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, | 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, |
| 4 | git_events::{KIND_LABEL, get_labels}, | 4 | git_events::{KIND_LABEL, get_labels}, |
| 5 | }; | 5 | }; |
| 6 | use nostr::{EventBuilder, Tag, TagStandard}; | 6 | use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; |
| 7 | use nostr_sdk::{EventId, FromBech32}; | 7 | use nostr_sdk::{EventId, FromBech32}; |
| 8 | use nostr::nips::nip19::Nip19; | ||
| 9 | 8 | ||
| 10 | use crate::{ | 9 | use crate::{ |
| 11 | client::{ | 10 | client::{ |
| 12 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | 11 | Client, Connect, fetching_with_report, get_events_from_local_cache, |
| 13 | save_event_in_local_cache, | 12 | get_repo_ref_from_cache, save_event_in_local_cache, |
| 14 | }, | 13 | }, |
| 15 | git::{Repo, RepoActions}, | 14 | git::{Repo, RepoActions}, |
| 16 | login, | 15 | login, |
| @@ -90,19 +89,13 @@ async fn publish_label_event( | |||
| 90 | 89 | ||
| 91 | // Permission check: only the author or a maintainer may label. | 90 | // Permission check: only the author or a maintainer may label. |
| 92 | if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { | 91 | if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { |
| 93 | bail!( | 92 | bail!("only the {target_kind} author or a repository maintainer can label a {target_kind}"); |
| 94 | "only the {target_kind} author or a repository maintainer can label a {target_kind}" | ||
| 95 | ); | ||
| 96 | } | 93 | } |
| 97 | 94 | ||
| 98 | // Fetch existing label events so we can warn about duplicates. | 95 | // Fetch existing label events so we can warn about duplicates. |
| 99 | let existing_label_events = get_events_from_local_cache( | 96 | let existing_label_events = get_events_from_local_cache( |
| 100 | git_repo_path, | 97 | git_repo_path, |
| 101 | vec![ | 98 | vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)], |
| 102 | nostr::Filter::default() | ||
| 103 | .event(event_id) | ||
| 104 | .kind(KIND_LABEL), | ||
| 105 | ], | ||
| 106 | ) | 99 | ) |
| 107 | .await?; | 100 | .await?; |
| 108 | 101 | ||
| @@ -117,10 +110,7 @@ async fn publish_label_event( | |||
| 117 | .collect(); | 110 | .collect(); |
| 118 | 111 | ||
| 119 | if new_labels.is_empty() { | 112 | if new_labels.is_empty() { |
| 120 | let already: Vec<String> = labels | 113 | let already: Vec<String> = labels.iter().map(|l| format!("#{}", l.trim())).collect(); |
| 121 | .iter() | ||
| 122 | .map(|l| format!("#{}", l.trim())) | ||
| 123 | .collect(); | ||
| 124 | println!( | 114 | println!( |
| 125 | "{target_kind} already has label{}: {}", | 115 | "{target_kind} already has label{}: {}", |
| 126 | if already.len() == 1 { "" } else { "s" }, | 116 | if already.len() == 1 { "" } else { "s" }, |
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index ee9840e..f040c63 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs | |||
| @@ -15,9 +15,10 @@ use ngit::{ | |||
| 15 | }, | 15 | }, |
| 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_COVER_NOTE, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, |
| 19 | get_commit_id_from_patch, get_labels_and_subject, | 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, process_cover_note, |
| 21 | status_kinds, tag_value, | ||
| 21 | }, | 22 | }, |
| 22 | repo_ref::{RepoRef, is_grasp_server_in_list}, | 23 | repo_ref::{RepoRef, is_grasp_server_in_list}, |
| 23 | }; | 24 | }; |
| @@ -222,7 +223,10 @@ pub async fn launch( | |||
| 222 | if !label_filter.is_empty() { | 223 | if !label_filter.is_empty() { |
| 223 | let proposal_labels_lower: HashSet<String> = | 224 | let proposal_labels_lower: HashSet<String> = |
| 224 | proposal_labels.iter().map(|l| l.to_lowercase()).collect(); | 225 | proposal_labels.iter().map(|l| l.to_lowercase()).collect(); |
| 225 | if !label_filter.iter().any(|l| proposal_labels_lower.contains(l)) { | 226 | if !label_filter |
| 227 | .iter() | ||
| 228 | .any(|l| proposal_labels_lower.contains(l)) | ||
| 229 | { | ||
| 226 | return None; | 230 | return None; |
| 227 | } | 231 | } |
| 228 | } | 232 | } |
| @@ -246,6 +250,16 @@ pub async fn launch( | |||
| 246 | .await? | 250 | .await? |
| 247 | .len() | 251 | .len() |
| 248 | }; | 252 | }; |
| 253 | // Fetch kind-1624 cover note events for this proposal. | ||
| 254 | let cover_note_events = get_events_from_local_cache( | ||
| 255 | git_repo_path, | ||
| 256 | vec![ | ||
| 257 | nostr::Filter::default() | ||
| 258 | .event(target_id) | ||
| 259 | .kind(KIND_COVER_NOTE), | ||
| 260 | ], | ||
| 261 | ) | ||
| 262 | .await?; | ||
| 249 | let relay_hint = repo_ref.relays.first(); | 263 | let relay_hint = repo_ref.relays.first(); |
| 250 | return show_proposal_details( | 264 | return show_proposal_details( |
| 251 | &filtered_proposals, | 265 | &filtered_proposals, |
| @@ -254,6 +268,8 @@ pub async fn launch( | |||
| 254 | show_comments, | 268 | show_comments, |
| 255 | comment_count, | 269 | comment_count, |
| 256 | &comments, | 270 | &comments, |
| 271 | &cover_note_events, | ||
| 272 | &repo_ref, | ||
| 257 | relay_hint, | 273 | relay_hint, |
| 258 | ); | 274 | ); |
| 259 | } | 275 | } |
| @@ -407,38 +423,40 @@ fn output_json( | |||
| 407 | ) -> Result<()> { | 423 | ) -> Result<()> { |
| 408 | let json_output: Vec<serde_json::Value> = proposals | 424 | let json_output: Vec<serde_json::Value> = proposals |
| 409 | .iter() | 425 | .iter() |
| 410 | .map(|(proposal, status_kind, proposal_labels, subject_override)| { | 426 | .map( |
| 411 | let id = event_id_to_nevent(proposal.id, relay_hint); | 427 | |(proposal, status_kind, proposal_labels, subject_override)| { |
| 412 | let status = status_kind_to_str(*status_kind).to_string(); | 428 | let id = event_id_to_nevent(proposal.id, relay_hint); |
| 413 | let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { | 429 | let status = status_kind_to_str(*status_kind).to_string(); |
| 414 | ( | 430 | let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { |
| 415 | subject_override.clone().unwrap_or(cl.title.clone()), | 431 | ( |
| 416 | proposal.pubkey.to_bech32().unwrap_or_default(), | 432 | subject_override.clone().unwrap_or(cl.title.clone()), |
| 417 | cl.get_branch_name_with_pr_prefix_and_shorthand_id() | 433 | proposal.pubkey.to_bech32().unwrap_or_default(), |
| 418 | .unwrap_or_default(), | 434 | cl.get_branch_name_with_pr_prefix_and_shorthand_id() |
| 419 | ) | 435 | .unwrap_or_default(), |
| 420 | } else { | ||
| 421 | let title = subject_override.clone().unwrap_or_else(|| { | ||
| 422 | tag_value(proposal, "description").map_or_else( | ||
| 423 | |_| proposal.id.to_string(), | ||
| 424 | |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(), | ||
| 425 | ) | 436 | ) |
| 426 | }); | 437 | } else { |
| 427 | ( | 438 | let title = subject_override.clone().unwrap_or_else(|| { |
| 428 | title, | 439 | tag_value(proposal, "description").map_or_else( |
| 429 | proposal.pubkey.to_bech32().unwrap_or_default(), | 440 | |_| proposal.id.to_string(), |
| 430 | String::new(), | 441 | |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(), |
| 431 | ) | 442 | ) |
| 432 | }; | 443 | }); |
| 433 | serde_json::json!({ | 444 | ( |
| 434 | "id": id, | 445 | title, |
| 435 | "status": status, | 446 | proposal.pubkey.to_bech32().unwrap_or_default(), |
| 436 | "subject": title, | 447 | String::new(), |
| 437 | "author": author, | 448 | ) |
| 438 | "branch": branch, | 449 | }; |
| 439 | "labels": proposal_labels, | 450 | serde_json::json!({ |
| 440 | }) | 451 | "id": id, |
| 441 | }) | 452 | "status": status, |
| 453 | "subject": title, | ||
| 454 | "author": author, | ||
| 455 | "branch": branch, | ||
| 456 | "labels": proposal_labels, | ||
| 457 | }) | ||
| 458 | }, | ||
| 459 | ) | ||
| 442 | .collect(); | 460 | .collect(); |
| 443 | 461 | ||
| 444 | println!("{}", serde_json::to_string_pretty(&json_output)?); | 462 | println!("{}", serde_json::to_string_pretty(&json_output)?); |
| @@ -472,7 +490,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> { | |||
| 472 | }) | 490 | }) |
| 473 | } | 491 | } |
| 474 | 492 | ||
| 475 | #[allow(clippy::too_many_lines)] | 493 | #[allow(clippy::too_many_lines, clippy::too_many_arguments)] |
| 476 | fn show_proposal_details( | 494 | fn show_proposal_details( |
| 477 | proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)], | 495 | proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)], |
| 478 | event_id_or_nevent: &str, | 496 | event_id_or_nevent: &str, |
| @@ -480,6 +498,8 @@ fn show_proposal_details( | |||
| 480 | show_comments: bool, | 498 | show_comments: bool, |
| 481 | comment_count: usize, | 499 | comment_count: usize, |
| 482 | comments: &[nostr::Event], | 500 | comments: &[nostr::Event], |
| 501 | cover_note_events: &[nostr::Event], | ||
| 502 | repo_ref: &RepoRef, | ||
| 483 | relay_hint: Option<&RelayUrl>, | 503 | relay_hint: Option<&RelayUrl>, |
| 484 | ) -> Result<()> { | 504 | ) -> Result<()> { |
| 485 | use nostr::ToBech32; | 505 | use nostr::ToBech32; |
| @@ -500,13 +520,41 @@ fn show_proposal_details( | |||
| 500 | .unwrap_or(&cover_letter.title) | 520 | .unwrap_or(&cover_letter.title) |
| 501 | .to_string(); | 521 | .to_string(); |
| 502 | 522 | ||
| 523 | // Resolve the effective cover note (kind 1624) for this proposal. | ||
| 524 | let cover_note = process_cover_note(proposal, repo_ref, cover_note_events); | ||
| 525 | |||
| 503 | if json { | 526 | if json { |
| 504 | let json_output = if show_comments { | 527 | let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| { |
| 528 | let mut obj = serde_json::json!({ | ||
| 529 | "id": event_id_to_nevent(cn.id, relay_hint), | ||
| 530 | "author": cn.pubkey.to_bech32().unwrap_or_default(), | ||
| 531 | "created_at": cn.created_at.as_secs(), | ||
| 532 | "body": cn.content, | ||
| 533 | }); | ||
| 534 | if *by_different_author { | ||
| 535 | obj["by_maintainer"] = serde_json::Value::Bool(true); | ||
| 536 | } | ||
| 537 | obj | ||
| 538 | }); | ||
| 539 | |||
| 540 | let mut json_obj = serde_json::json!({ | ||
| 541 | "id": event_id_to_nevent(proposal.id, relay_hint), | ||
| 542 | "status": status_kind_to_str(*status_kind), | ||
| 543 | "subject": display_title, | ||
| 544 | "author": proposal.pubkey.to_bech32().unwrap_or_default(), | ||
| 545 | "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, | ||
| 546 | "labels": proposal_labels, | ||
| 547 | "comment_count": comment_count, | ||
| 548 | "description": cover_letter.description, | ||
| 549 | }); | ||
| 550 | if let Some(cn) = cover_note_json { | ||
| 551 | json_obj["cover_note"] = cn; | ||
| 552 | } | ||
| 553 | if show_comments { | ||
| 505 | let comments_json: Vec<serde_json::Value> = comments | 554 | let comments_json: Vec<serde_json::Value> = comments |
| 506 | .iter() | 555 | .iter() |
| 507 | .map(|c| { | 556 | .map(|c| { |
| 508 | let reply_to = | 557 | let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); |
| 509 | comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); | ||
| 510 | serde_json::json!({ | 558 | serde_json::json!({ |
| 511 | "id": event_id_to_nevent(c.id, relay_hint), | 559 | "id": event_id_to_nevent(c.id, relay_hint), |
| 512 | "author": c.pubkey.to_bech32().unwrap_or_default(), | 560 | "author": c.pubkey.to_bech32().unwrap_or_default(), |
| @@ -516,30 +564,9 @@ fn show_proposal_details( | |||
| 516 | }) | 564 | }) |
| 517 | }) | 565 | }) |
| 518 | .collect(); | 566 | .collect(); |
| 519 | serde_json::json!({ | 567 | json_obj["comments"] = serde_json::Value::Array(comments_json); |
| 520 | "id": event_id_to_nevent(proposal.id, relay_hint), | 568 | } |
| 521 | "status": status_kind_to_str(*status_kind), | 569 | println!("{}", serde_json::to_string_pretty(&json_obj)?); |
| 522 | "subject": display_title, | ||
| 523 | "author": proposal.pubkey.to_bech32().unwrap_or_default(), | ||
| 524 | "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, | ||
| 525 | "labels": proposal_labels, | ||
| 526 | "comment_count": comment_count, | ||
| 527 | "comments": comments_json, | ||
| 528 | "description": cover_letter.description, | ||
| 529 | }) | ||
| 530 | } else { | ||
| 531 | serde_json::json!({ | ||
| 532 | "id": event_id_to_nevent(proposal.id, relay_hint), | ||
| 533 | "status": status_kind_to_str(*status_kind), | ||
| 534 | "subject": display_title, | ||
| 535 | "author": proposal.pubkey.to_bech32().unwrap_or_default(), | ||
| 536 | "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, | ||
| 537 | "labels": proposal_labels, | ||
| 538 | "comment_count": comment_count, | ||
| 539 | "description": cover_letter.description, | ||
| 540 | }) | ||
| 541 | }; | ||
| 542 | println!("{}", serde_json::to_string_pretty(&json_output)?); | ||
| 543 | return Ok(()); | 570 | return Ok(()); |
| 544 | } | 571 | } |
| 545 | 572 | ||
| @@ -562,7 +589,28 @@ fn show_proposal_details( | |||
| 562 | println!("Labels: {labels_str}"); | 589 | println!("Labels: {labels_str}"); |
| 563 | } | 590 | } |
| 564 | 591 | ||
| 565 | if !cover_letter.description.is_empty() { | 592 | if let Some((cn, by_different_author)) = &cover_note { |
| 593 | println!(); | ||
| 594 | if *by_different_author { | ||
| 595 | println!( | ||
| 596 | "Cover Note (by {}):", | ||
| 597 | cn.pubkey.to_bech32().unwrap_or_default() | ||
| 598 | ); | ||
| 599 | } else { | ||
| 600 | println!("Cover Note:"); | ||
| 601 | } | ||
| 602 | for line in cn.content.lines() { | ||
| 603 | println!(" {line}"); | ||
| 604 | } | ||
| 605 | // Show original description only when --comments is used. | ||
| 606 | if show_comments && !cover_letter.description.is_empty() { | ||
| 607 | println!(); | ||
| 608 | println!("Original Description:"); | ||
| 609 | for line in cover_letter.description.lines() { | ||
| 610 | println!(" {line}"); | ||
| 611 | } | ||
| 612 | } | ||
| 613 | } else if !cover_letter.description.is_empty() { | ||
| 566 | println!(); | 614 | println!(); |
| 567 | println!("Description:"); | 615 | println!("Description:"); |
| 568 | for line in cover_letter.description.lines() { | 616 | for line in cover_letter.description.lines() { |
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index db8ea54..6d7f2e2 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs | |||
| @@ -15,6 +15,7 @@ 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_cover_note; | ||
| 18 | pub mod set_subject; | 19 | pub mod set_subject; |
| 19 | pub mod sync; | 20 | pub mod sync; |
| 20 | pub mod whoami; | 21 | pub mod whoami; |
diff --git a/src/bin/ngit/sub_commands/pr_status.rs b/src/bin/ngit/sub_commands/pr_status.rs index 4a51bb3..f3ac627 100644 --- a/src/bin/ngit/sub_commands/pr_status.rs +++ b/src/bin/ngit/sub_commands/pr_status.rs | |||
| @@ -206,5 +206,12 @@ pub async fn launch_ready(id: &str, offline: bool, reason: Option<&str>) -> Resu | |||
| 206 | } | 206 | } |
| 207 | 207 | ||
| 208 | pub async fn launch_draft(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { | 208 | pub async fn launch_draft(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { |
| 209 | launch_status(id, offline, Kind::GitStatusDraft, "converted to draft", reason).await | 209 | launch_status( |
| 210 | id, | ||
| 211 | offline, | ||
| 212 | Kind::GitStatusDraft, | ||
| 213 | "converted to draft", | ||
| 214 | reason, | ||
| 215 | ) | ||
| 216 | .await | ||
| 210 | } | 217 | } |
diff --git a/src/bin/ngit/sub_commands/repo/mod.rs b/src/bin/ngit/sub_commands/repo/mod.rs index 63d96bd..766b025 100644 --- a/src/bin/ngit/sub_commands/repo/mod.rs +++ b/src/bin/ngit/sub_commands/repo/mod.rs | |||
| @@ -74,6 +74,7 @@ struct RepoInfoJson { | |||
| 74 | // `ngit repo` (no subcommand) — show repository info | 74 | // `ngit repo` (no subcommand) — show repository info |
| 75 | // --------------------------------------------------------------------------- | 75 | // --------------------------------------------------------------------------- |
| 76 | 76 | ||
| 77 | #[allow(clippy::too_many_lines)] | ||
| 77 | async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { | 78 | async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { |
| 78 | let git_repo = Repo::discover().context("failed to find a git repository")?; | 79 | let git_repo = Repo::discover().context("failed to find a git repository")?; |
| 79 | let git_repo_path = git_repo.get_path()?; | 80 | let git_repo_path = git_repo.get_path()?; |
| @@ -98,20 +99,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { | |||
| 98 | 99 | ||
| 99 | let Some(repo_coordinate) = repo_coordinate else { | 100 | let Some(repo_coordinate) = repo_coordinate else { |
| 100 | if json { | 101 | if json { |
| 101 | println!("{}", serde_json::to_string_pretty(&RepoInfoJson { | 102 | println!( |
| 102 | is_nostr_repo: false, | 103 | "{}", |
| 103 | name: None, | 104 | serde_json::to_string_pretty(&RepoInfoJson { |
| 104 | identifier: None, | 105 | is_nostr_repo: false, |
| 105 | description: None, | 106 | name: None, |
| 106 | nostr_url: None, | 107 | identifier: None, |
| 107 | coordinate: None, | 108 | description: None, |
| 108 | web: None, | 109 | nostr_url: None, |
| 109 | maintainers: None, | 110 | coordinate: None, |
| 110 | grasp_servers: None, | 111 | web: None, |
| 111 | git_servers: None, | 112 | maintainers: None, |
| 112 | relays: None, | 113 | grasp_servers: None, |
| 113 | hashtags: None, | 114 | git_servers: None, |
| 114 | })?); | 115 | relays: None, |
| 116 | hashtags: None, | ||
| 117 | })? | ||
| 118 | ); | ||
| 115 | } else { | 119 | } else { |
| 116 | println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); | 120 | println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); |
| 117 | println!(); | 121 | println!(); |
| @@ -140,20 +144,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { | |||
| 140 | .ok() | 144 | .ok() |
| 141 | .and_then(|r| r.url().map(std::string::ToString::to_string)) | 145 | .and_then(|r| r.url().map(std::string::ToString::to_string)) |
| 142 | .filter(|u| u.starts_with("nostr://")); | 146 | .filter(|u| u.starts_with("nostr://")); |
| 143 | println!("{}", serde_json::to_string_pretty(&RepoInfoJson { | 147 | println!( |
| 144 | is_nostr_repo: true, | 148 | "{}", |
| 145 | name: None, | 149 | serde_json::to_string_pretty(&RepoInfoJson { |
| 146 | identifier: Some(repo_coordinate.identifier.clone()), | 150 | is_nostr_repo: true, |
| 147 | description: None, | 151 | name: None, |
| 148 | nostr_url, | 152 | identifier: Some(repo_coordinate.identifier.clone()), |
| 149 | coordinate: repo_coordinate.to_bech32().ok(), | 153 | description: None, |
| 150 | web: None, | 154 | nostr_url, |
| 151 | maintainers: None, | 155 | coordinate: repo_coordinate.to_bech32().ok(), |
| 152 | grasp_servers: None, | 156 | web: None, |
| 153 | git_servers: None, | 157 | maintainers: None, |
| 154 | relays: None, | 158 | grasp_servers: None, |
| 155 | hashtags: None, | 159 | git_servers: None, |
| 156 | })?); | 160 | relays: None, |
| 161 | hashtags: None, | ||
| 162 | })? | ||
| 163 | ); | ||
| 157 | } else { | 164 | } else { |
| 158 | println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); | 165 | println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); |
| 159 | println!(); | 166 | println!(); |
| @@ -162,8 +169,12 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { | |||
| 162 | repo_coordinate.identifier | 169 | repo_coordinate.identifier |
| 163 | ); | 170 | ); |
| 164 | println!(); | 171 | println!(); |
| 165 | println!("if you created this repository, run `ngit repo init` to publish an announcement"); | 172 | println!( |
| 166 | println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement"); | 173 | "if you created this repository, run `ngit repo init` to publish an announcement" |
| 174 | ); | ||
| 175 | println!( | ||
| 176 | "if you are a co-maintainer, run `ngit repo accept` to publish your announcement" | ||
| 177 | ); | ||
| 167 | } | 178 | } |
| 168 | return Ok(()); | 179 | return Ok(()); |
| 169 | }; | 180 | }; |
diff --git a/src/bin/ngit/sub_commands/set_cover_note.rs b/src/bin/ngit/sub_commands/set_cover_note.rs new file mode 100644 index 0000000..49d4f34 --- /dev/null +++ b/src/bin/ngit/sub_commands/set_cover_note.rs | |||
| @@ -0,0 +1,202 @@ | |||
| 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 | content_tags::{dedup_tags, tags_from_content}, | ||
| 5 | git_events::{KIND_COVER_NOTE, process_cover_note}, | ||
| 6 | }; | ||
| 7 | use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; | ||
| 8 | use nostr_sdk::{EventId, FromBech32}; | ||
| 9 | |||
| 10 | use crate::{ | ||
| 11 | client::{ | ||
| 12 | Client, Connect, fetching_with_report, get_events_from_local_cache, | ||
| 13 | get_repo_ref_from_cache, 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 kind-1624 cover note event for `target`. | ||
| 35 | /// | ||
| 36 | /// A cover note is a markdown body that replaces the displayed description of a | ||
| 37 | /// PR, patch or issue. Only the author of the target event or a repository | ||
| 38 | /// maintainer may set it. The latest authorised event wins (replaceable | ||
| 39 | /// semantics with hex-id tiebreak). | ||
| 40 | /// | ||
| 41 | /// The `body` is processed for `nostr:` mentions (NIP-21), which are converted | ||
| 42 | /// to `q` (event) and `p` (pubkey) tags — the same rules as `--body` in issue | ||
| 43 | /// creation. | ||
| 44 | #[allow(clippy::too_many_lines)] | ||
| 45 | async fn publish_set_cover_note_event( | ||
| 46 | id: &str, | ||
| 47 | body: &str, | ||
| 48 | offline: bool, | ||
| 49 | target_kind: &str, // "issue" or "PR" — used in error messages | ||
| 50 | ) -> Result<()> { | ||
| 51 | let body = body.trim(); | ||
| 52 | if body.is_empty() { | ||
| 53 | bail!("--body value must not be empty"); | ||
| 54 | } | ||
| 55 | |||
| 56 | let event_id = parse_event_id(id)?; | ||
| 57 | |||
| 58 | let git_repo = Repo::discover().context("failed to find a git repository")?; | ||
| 59 | let git_repo_path = git_repo.get_path()?; | ||
| 60 | |||
| 61 | let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | ||
| 62 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; | ||
| 63 | |||
| 64 | if !offline { | ||
| 65 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 66 | } | ||
| 67 | |||
| 68 | let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; | ||
| 69 | |||
| 70 | // Resolve the target event from cache. | ||
| 71 | let target = if target_kind == "issue" { | ||
| 72 | let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 73 | issues | ||
| 74 | .into_iter() | ||
| 75 | .find(|e| e.id == event_id) | ||
| 76 | .context(format!( | ||
| 77 | "issue with id {} not found in cache", | ||
| 78 | event_id.to_hex() | ||
| 79 | ))? | ||
| 80 | } else { | ||
| 81 | let proposals = | ||
| 82 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 83 | proposals | ||
| 84 | .into_iter() | ||
| 85 | .find(|e| e.id == event_id) | ||
| 86 | .context(format!( | ||
| 87 | "PR with id {} not found in cache", | ||
| 88 | event_id.to_hex() | ||
| 89 | ))? | ||
| 90 | }; | ||
| 91 | |||
| 92 | // Login — we need the signer and user pubkey. | ||
| 93 | let (signer, user_ref, _) = | ||
| 94 | login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; | ||
| 95 | |||
| 96 | let user_pubkey = signer.get_public_key().await?; | ||
| 97 | |||
| 98 | // Permission check: only the author or a maintainer may set a cover note. | ||
| 99 | if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { | ||
| 100 | bail!( | ||
| 101 | "only the {target_kind} author or a repository maintainer can set the cover note of a {target_kind}" | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | |||
| 105 | // Fetch existing cover note events so we can check whether the body is | ||
| 106 | // already set to the requested value. | ||
| 107 | let existing_cover_note_events = get_events_from_local_cache( | ||
| 108 | git_repo_path, | ||
| 109 | vec![ | ||
| 110 | nostr::Filter::default() | ||
| 111 | .event(event_id) | ||
| 112 | .kind(KIND_COVER_NOTE), | ||
| 113 | ], | ||
| 114 | ) | ||
| 115 | .await?; | ||
| 116 | |||
| 117 | if let Some((existing_cn, _)) = | ||
| 118 | process_cover_note(&target, &repo_ref, &existing_cover_note_events) | ||
| 119 | { | ||
| 120 | if existing_cn.content.trim() == body { | ||
| 121 | println!( | ||
| 122 | "{target_kind} {} already has this cover note", | ||
| 123 | &event_id.to_hex()[..8], | ||
| 124 | ); | ||
| 125 | return Ok(()); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | // Build the kind-1624 cover note event. | ||
| 130 | // | ||
| 131 | // Shape: | ||
| 132 | // content: "<markdown>" | ||
| 133 | // tags: | ||
| 134 | // ["e", "<pr-issue-or-patch-id>", "<relay-hint>"] — reference to target | ||
| 135 | // ["p", "<author-pubkey>"] — notify the author | ||
| 136 | // ["q", "<referenced-event>", ...] — from body mentions | ||
| 137 | // ["p", "<referenced-pubkey>", ...] — from body mentions | ||
| 138 | // ["alt", "cover note for <target_kind>"] | ||
| 139 | let relay_hint = repo_ref.relays.first().cloned(); | ||
| 140 | |||
| 141 | let mut tags: Vec<Tag> = vec![]; | ||
| 142 | |||
| 143 | // Reference the target event (lowercase `e`). | ||
| 144 | tags.push(Tag::from_standardized(TagStandard::Event { | ||
| 145 | event_id: target.id, | ||
| 146 | relay_url: relay_hint.clone(), | ||
| 147 | marker: None, | ||
| 148 | public_key: None, | ||
| 149 | uppercase: false, | ||
| 150 | })); | ||
| 151 | |||
| 152 | // Notify the target event author. | ||
| 153 | tags.push(Tag::public_key(target.pubkey)); | ||
| 154 | |||
| 155 | // Human-readable alt text. | ||
| 156 | tags.push(Tag::custom( | ||
| 157 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 158 | vec![format!("cover note for {target_kind}")], | ||
| 159 | )); | ||
| 160 | |||
| 161 | // Process body for nostr: mentions → q and p tags (same as --body in issue | ||
| 162 | // creation). | ||
| 163 | let mention_tags = tags_from_content(body, Some(git_repo_path)).await?; | ||
| 164 | tags.extend(mention_tags); | ||
| 165 | let tags = dedup_tags(tags); | ||
| 166 | |||
| 167 | let cover_note_event = ngit::client::sign_event( | ||
| 168 | EventBuilder::new(KIND_COVER_NOTE, body).tags(tags), | ||
| 169 | &signer, | ||
| 170 | format!("set {target_kind} cover note"), | ||
| 171 | ) | ||
| 172 | .await?; | ||
| 173 | |||
| 174 | // Save to local cache immediately so subsequent reads reflect the new cover | ||
| 175 | // note. | ||
| 176 | save_event_in_local_cache(git_repo_path, &cover_note_event).await?; | ||
| 177 | |||
| 178 | let mut client = client; | ||
| 179 | client.set_signer(signer).await; | ||
| 180 | |||
| 181 | send_events( | ||
| 182 | &client, | ||
| 183 | Some(git_repo_path), | ||
| 184 | vec![cover_note_event], | ||
| 185 | user_ref.relays.write(), | ||
| 186 | repo_ref.relays.clone(), | ||
| 187 | true, | ||
| 188 | false, | ||
| 189 | ) | ||
| 190 | .await?; | ||
| 191 | |||
| 192 | println!("{} {} cover note set", target_kind, &event_id.to_hex()[..8],); | ||
| 193 | Ok(()) | ||
| 194 | } | ||
| 195 | |||
| 196 | pub async fn launch_issue_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> { | ||
| 197 | publish_set_cover_note_event(id, body, offline, "issue").await | ||
| 198 | } | ||
| 199 | |||
| 200 | pub async fn launch_pr_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> { | ||
| 201 | publish_set_cover_note_event(id, body, offline, "PR").await | ||
| 202 | } | ||
diff --git a/src/bin/ngit/sub_commands/set_subject.rs b/src/bin/ngit/sub_commands/set_subject.rs index 65ff1d3..0dc16f5 100644 --- a/src/bin/ngit/sub_commands/set_subject.rs +++ b/src/bin/ngit/sub_commands/set_subject.rs | |||
| @@ -3,14 +3,13 @@ use ngit::{ | |||
| 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, | 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, |
| 4 | git_events::{KIND_LABEL, get_labels_and_subject}, | 4 | git_events::{KIND_LABEL, get_labels_and_subject}, |
| 5 | }; | 5 | }; |
| 6 | use nostr::{EventBuilder, Tag, TagStandard}; | 6 | use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; |
| 7 | use nostr_sdk::{EventId, FromBech32}; | 7 | use nostr_sdk::{EventId, FromBech32}; |
| 8 | use nostr::nips::nip19::Nip19; | ||
| 9 | 8 | ||
| 10 | use crate::{ | 9 | use crate::{ |
| 11 | client::{ | 10 | client::{ |
| 12 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | 11 | Client, Connect, fetching_with_report, get_events_from_local_cache, |
| 13 | save_event_in_local_cache, | 12 | get_repo_ref_from_cache, save_event_in_local_cache, |
| 14 | }, | 13 | }, |
| 15 | git::{Repo, RepoActions}, | 14 | git::{Repo, RepoActions}, |
| 16 | login, | 15 | login, |
| @@ -102,11 +101,7 @@ async fn publish_set_subject_event( | |||
| 102 | // already set to the requested value. | 101 | // already set to the requested value. |
| 103 | let existing_label_events = get_events_from_local_cache( | 102 | let existing_label_events = get_events_from_local_cache( |
| 104 | git_repo_path, | 103 | git_repo_path, |
| 105 | vec![ | 104 | vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)], |
| 106 | nostr::Filter::default() | ||
| 107 | .event(event_id) | ||
| 108 | .kind(KIND_LABEL), | ||
| 109 | ], | ||
| 110 | ) | 105 | ) |
| 111 | .await?; | 106 | .await?; |
| 112 | 107 | ||
diff --git a/src/bin/ngit/sub_commands/whoami.rs b/src/bin/ngit/sub_commands/whoami.rs index be79c79..19ce573 100644 --- a/src/bin/ngit/sub_commands/whoami.rs +++ b/src/bin/ngit/sub_commands/whoami.rs | |||
| @@ -154,7 +154,14 @@ async fn load_user_for_scope( | |||
| 154 | ) -> Option<(String, String, Option<String>)> { | 154 | ) -> Option<(String, String, Option<String>)> { |
| 155 | // First verify signer info exists for this scope without building a full | 155 | // First verify signer info exists for this scope without building a full |
| 156 | // signer — avoids triggering password prompts for ncryptsec. | 156 | // signer — avoids triggering password prompts for ncryptsec. |
| 157 | if get_signer_info(&git_repo, &signer_info.cloned(), &None, &Some(source.clone())).is_err() { | 157 | if get_signer_info( |
| 158 | &git_repo, | ||
| 159 | &signer_info.cloned(), | ||
| 160 | &None, | ||
| 161 | &Some(source.clone()), | ||
| 162 | ) | ||
| 163 | .is_err() | ||
| 164 | { | ||
| 158 | return None; | 165 | return None; |
| 159 | } | 166 | } |
| 160 | 167 | ||
diff --git a/src/lib/client.rs b/src/lib/client.rs index 94a173f..d5597fa 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs | |||
| @@ -56,7 +56,7 @@ use crate::{ | |||
| 56 | get_dirs, | 56 | get_dirs, |
| 57 | git::{Repo, RepoActions, get_git_config_item}, | 57 | git::{Repo, RepoActions, get_git_config_item}, |
| 58 | git_events::{ | 58 | git_events::{ |
| 59 | KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, | 59 | KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, |
| 60 | KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, | 60 | KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, |
| 61 | event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, | 61 | event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, |
| 62 | }, | 62 | }, |
| @@ -2000,6 +2000,8 @@ async fn process_fetched_events( | |||
| 2000 | report.comments.insert(event.id); | 2000 | report.comments.insert(event.id); |
| 2001 | } else if event.kind.eq(&KIND_LABEL) { | 2001 | } else if event.kind.eq(&KIND_LABEL) { |
| 2002 | report.labels.insert(event.id); | 2002 | report.labels.insert(event.id); |
| 2003 | } else if event.kind.eq(&KIND_COVER_NOTE) { | ||
| 2004 | report.cover_notes.insert(event.id); | ||
| 2003 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) | 2005 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) |
| 2004 | { | 2006 | { |
| 2005 | if request.missing_contributor_profiles.contains(&event.pubkey) { | 2007 | if request.missing_contributor_profiles.contains(&event.pubkey) { |
| @@ -2126,6 +2128,9 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo | |||
| 2126 | for c in relay_report.labels { | 2128 | for c in relay_report.labels { |
| 2127 | report.labels.insert(c); | 2129 | report.labels.insert(c); |
| 2128 | } | 2130 | } |
| 2131 | for c in relay_report.cover_notes { | ||
| 2132 | report.cover_notes.insert(c); | ||
| 2133 | } | ||
| 2129 | report.deletions += relay_report.deletions; | 2134 | report.deletions += relay_report.deletions; |
| 2130 | for c in relay_report.contributor_profiles { | 2135 | for c in relay_report.contributor_profiles { |
| 2131 | report.contributor_profiles.insert(c); | 2136 | report.contributor_profiles.insert(c); |
| @@ -2268,6 +2273,24 @@ pub fn get_fetch_filters( | |||
| 2268 | ] | 2273 | ] |
| 2269 | } | 2274 | } |
| 2270 | }, | 2275 | }, |
| 2276 | // Fetch kind-1624 cover note events for issues and proposals. | ||
| 2277 | // Cover notes reference the target via a lowercase `e` tag. | ||
| 2278 | { | ||
| 2279 | let all_root_ids: HashSet<EventId> = issue_ids | ||
| 2280 | .iter() | ||
| 2281 | .chain(proposal_ids.iter()) | ||
| 2282 | .copied() | ||
| 2283 | .collect(); | ||
| 2284 | if all_root_ids.is_empty() { | ||
| 2285 | vec![] | ||
| 2286 | } else { | ||
| 2287 | vec![ | ||
| 2288 | nostr::Filter::default() | ||
| 2289 | .events(all_root_ids) | ||
| 2290 | .kind(KIND_COVER_NOTE), | ||
| 2291 | ] | ||
| 2292 | } | ||
| 2293 | }, | ||
| 2271 | // Request kind-5 deletions for state events and repo announcements by | 2294 | // Request kind-5 deletions for state events and repo announcements by |
| 2272 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above | 2295 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above |
| 2273 | // covers addressable-event deletions; this covers the specific event IDs | 2296 | // covers addressable-event deletions; this covers the specific event IDs |
| @@ -2358,6 +2381,8 @@ pub struct FetchReport { | |||
| 2358 | comments: HashSet<EventId>, | 2381 | comments: HashSet<EventId>, |
| 2359 | /// NIP-32 kind-1985 label events for issues and proposals. | 2382 | /// NIP-32 kind-1985 label events for issues and proposals. |
| 2360 | labels: HashSet<EventId>, | 2383 | labels: HashSet<EventId>, |
| 2384 | /// Kind-1624 cover note events for issues, patches, and PRs. | ||
| 2385 | cover_notes: HashSet<EventId>, | ||
| 2361 | /// Count of kind-5 deletion events received (for display purposes). | 2386 | /// Count of kind-5 deletion events received (for display purposes). |
| 2362 | deletions: u32, | 2387 | deletions: u32, |
| 2363 | contributor_profiles: HashSet<PublicKey>, | 2388 | contributor_profiles: HashSet<PublicKey>, |
| @@ -2453,6 +2478,13 @@ impl Display for FetchReport { | |||
| 2453 | if self.labels.len() > 1 { "s" } else { "" }, | 2478 | if self.labels.len() > 1 { "s" } else { "" }, |
| 2454 | )); | 2479 | )); |
| 2455 | } | 2480 | } |
| 2481 | if !self.cover_notes.is_empty() { | ||
| 2482 | display_items.push(format!( | ||
| 2483 | "{} cover note{}", | ||
| 2484 | self.cover_notes.len(), | ||
| 2485 | if self.cover_notes.len() > 1 { "s" } else { "" }, | ||
| 2486 | )); | ||
| 2487 | } | ||
| 2456 | if self.deletions > 0 { | 2488 | if self.deletions > 0 { |
| 2457 | display_items.push(format!( | 2489 | display_items.push(format!( |
| 2458 | "{} deletion{}", | 2490 | "{} deletion{}", |
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index b512e44..a5793a5 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs | |||
| @@ -94,6 +94,10 @@ pub const KIND_COMMENT: Kind = Kind::Custom(1111); | |||
| 94 | /// NIP-32 label event (kind 1985) — applies hashtag labels to an existing | 94 | /// NIP-32 label event (kind 1985) — applies hashtag labels to an existing |
| 95 | /// event. Used to add labels to issues, patches and PRs after the fact. | 95 | /// event. Used to add labels to issues, patches and PRs after the fact. |
| 96 | pub const KIND_LABEL: Kind = Kind::Custom(1985); | 96 | pub const KIND_LABEL: Kind = Kind::Custom(1985); |
| 97 | /// Cover note event (kind 1624) — a markdown note attached to a PR, patch or | ||
| 98 | /// issue by its author or a repository maintainer. Only the latest authorised | ||
| 99 | /// event is displayed (replaceable semantics with hex-id tiebreak). | ||
| 100 | pub const KIND_COVER_NOTE: Kind = Kind::Custom(1624); | ||
| 97 | 101 | ||
| 98 | pub fn event_is_patch_set_root(event: &Event) -> bool { | 102 | pub fn event_is_patch_set_root(event: &Event) -> bool { |
| 99 | event.kind.eq(&Kind::GitPatch) | 103 | event.kind.eq(&Kind::GitPatch) |
| @@ -985,19 +989,15 @@ pub fn is_event_proposal_root_for_branch( | |||
| 985 | /// | 989 | /// |
| 986 | /// 1. `t` tags on the event itself (self-reported by the event author). | 990 | /// 1. `t` tags on the event itself (self-reported by the event author). |
| 987 | /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` | 991 | /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` |
| 988 | /// via a lowercase `e` tag and carry `["L", "#t"]` + | 992 | /// via a lowercase `e` tag and carry `["L", "#t"]` + `["l", "<value>", |
| 989 | /// `["l", "<value>", "#t"]` tags. | 993 | /// "#t"]` tags. |
| 990 | /// | 994 | /// |
| 991 | /// A label is only applied when the author of the source event is either the | 995 | /// A label is only applied when the author of the source event is either the |
| 992 | /// author of `event` itself or one of the repository maintainers. | 996 | /// author of `event` itself or one of the repository maintainers. |
| 993 | /// | 997 | /// |
| 994 | /// Labels are additive — all valid label events contribute; there is no | 998 | /// Labels are additive — all valid label events contribute; there is no |
| 995 | /// "latest wins" replacement semantics. | 999 | /// "latest wins" replacement semantics. |
| 996 | pub fn process_labels( | 1000 | pub fn process_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> { |
| 997 | event: &Event, | ||
| 998 | repo_ref: &RepoRef, | ||
| 999 | label_events: &[Event], | ||
| 1000 | ) -> Vec<String> { | ||
| 1001 | let is_permitted = |pubkey: &PublicKey| -> bool { | 1001 | let is_permitted = |pubkey: &PublicKey| -> bool { |
| 1002 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | 1002 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) |
| 1003 | }; | 1003 | }; |
| @@ -1162,12 +1162,57 @@ pub fn get_labels_and_subject( | |||
| 1162 | /// Compatibility wrapper — returns only the hashtag labels. | 1162 | /// Compatibility wrapper — returns only the hashtag labels. |
| 1163 | /// | 1163 | /// |
| 1164 | /// Prefer [`get_labels_and_subject`] when the subject override is also needed. | 1164 | /// Prefer [`get_labels_and_subject`] when the subject override is also needed. |
| 1165 | pub fn get_labels( | 1165 | pub fn get_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> { |
| 1166 | process_labels(event, repo_ref, label_events) | ||
| 1167 | } | ||
| 1168 | |||
| 1169 | /// The effective cover note for `event`, selected from a pre-fetched set of | ||
| 1170 | /// kind-1624 events. | ||
| 1171 | /// | ||
| 1172 | /// A cover note is a markdown body attached to a PR, patch or issue by its | ||
| 1173 | /// author or a repository maintainer. Only the latest authorised event wins | ||
| 1174 | /// (replaceable semantics: newest `created_at`, tiebreak by lexicographically | ||
| 1175 | /// larger event ID). Events authored by other pubkeys are ignored. | ||
| 1176 | /// | ||
| 1177 | /// Returns `None` when no valid cover note exists. | ||
| 1178 | pub fn process_cover_note( | ||
| 1166 | event: &Event, | 1179 | event: &Event, |
| 1167 | repo_ref: &RepoRef, | 1180 | repo_ref: &RepoRef, |
| 1168 | label_events: &[Event], | 1181 | cover_note_events: &[Event], |
| 1169 | ) -> Vec<String> { | 1182 | ) -> Option<(Event, bool)> { |
| 1170 | process_labels(event, repo_ref, label_events) | 1183 | let is_permitted = |pubkey: &PublicKey| -> bool { |
| 1184 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | ||
| 1185 | }; | ||
| 1186 | |||
| 1187 | let event_id_str = event.id.to_string(); | ||
| 1188 | |||
| 1189 | // Find the winning cover note: latest created_at, tiebreak by | ||
| 1190 | // lexicographically larger event ID (NIP-1 replaceable event semantics). | ||
| 1191 | let winner = cover_note_events | ||
| 1192 | .iter() | ||
| 1193 | .filter(|cn| { | ||
| 1194 | if !cn.kind.eq(&KIND_COVER_NOTE) { | ||
| 1195 | return false; | ||
| 1196 | } | ||
| 1197 | if !is_permitted(&cn.pubkey) { | ||
| 1198 | return false; | ||
| 1199 | } | ||
| 1200 | // Must reference our event via a lowercase `e` tag. | ||
| 1201 | cn.tags.iter().any(|t| { | ||
| 1202 | let s = t.as_slice(); | ||
| 1203 | s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) | ||
| 1204 | }) | ||
| 1205 | }) | ||
| 1206 | .max_by(|a, b| { | ||
| 1207 | a.created_at | ||
| 1208 | .cmp(&b.created_at) | ||
| 1209 | .then_with(|| a.id.to_string().cmp(&b.id.to_string())) | ||
| 1210 | })?; | ||
| 1211 | |||
| 1212 | // True when the cover note author differs from the original event author | ||
| 1213 | // (i.e. a maintainer wrote it, not the PR/issue author). | ||
| 1214 | let by_different_author = winner.pubkey != event.pubkey; | ||
| 1215 | Some((winner.clone(), by_different_author)) | ||
| 1171 | } | 1216 | } |
| 1172 | 1217 | ||
| 1173 | pub fn get_status( | 1218 | pub fn get_status( |