diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 11:28:36 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 11:28:36 +0000 |
| commit | 0e493c455a0345c206dd1c5b0dfb5322b8a4e3e9 (patch) | |
| tree | 105517fc7a592e469a0f72667f9b364895052287 | |
| parent | 2f2819cc2365be07fedfd35ab3654b3607e29e76 (diff) | |
feat(labels): fetch and apply NIP-32 kind-1985 label events
- Add KIND_LABEL (kind 1985) constant to git_events.rs
- Add get_labels() merging inline t-tags with external kind-1985 events,
gating each on author-or-maintainer permission
- Extend get_fetch_filters() to request kind-1985 events for all known
issue and proposal IDs
- Track label event counts in FetchReport (field + Display + consolidation)
- Update issue_list.rs and list.rs to fetch label events from cache and
pass them through get_labels() instead of reading t-tags inline
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 27 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 83 | ||||
| -rw-r--r-- | src/lib/client.rs | 38 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 88 |
5 files changed, 171 insertions, 68 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 182fb6c..5a9fc51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md | |||
| @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 9 | 9 | ||
| 10 | ### Added | 10 | ### Added |
| 11 | 11 | ||
| 12 | - NIP-32 label support: kind-1985 label events are now fetched alongside status events and merged with inline `t` tags to compute the effective label set for issues and PRs; only labels authored by the issue/PR author or a repository maintainer are applied; label counts appear in the fetch progress report | ||
| 13 | - `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event | ||
| 14 | - `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event | ||
| 12 | - `ngit account whoami` — show the currently logged-in account(s) | 15 | - `ngit account whoami` — show the currently logged-in account(s) |
| 13 | - `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged | 16 | - `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged |
| 14 | - `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order | 17 | - `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order |
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index d7c8ac9..22b1b8a 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, get_status, status_kinds, tag_value}, | 6 | git_events::{KIND_COMMENT, KIND_LABEL, get_labels, get_status, status_kinds, tag_value}, |
| 7 | }; | 7 | }; |
| 8 | use nostr::{ | 8 | use nostr::{ |
| 9 | FromBech32, ToBech32, | 9 | FromBech32, ToBech32, |
| @@ -38,17 +38,7 @@ fn get_issue_title(event: &nostr::Event) -> String { | |||
| 38 | }) | 38 | }) |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | fn get_issue_labels(event: &nostr::Event) -> Vec<String> { | 41 | |
| 42 | event | ||
| 43 | .tags | ||
| 44 | .iter() | ||
| 45 | .filter(|t| { | ||
| 46 | let s = t.as_slice(); | ||
| 47 | s.len() >= 2 && s[0].eq("t") | ||
| 48 | }) | ||
| 49 | .map(|t| t.as_slice()[1].clone()) | ||
| 50 | .collect() | ||
| 51 | } | ||
| 52 | 42 | ||
| 53 | fn status_kind_to_str(kind: Kind) -> &'static str { | 43 | fn status_kind_to_str(kind: Kind) -> &'static str { |
| 54 | match kind { | 44 | match kind { |
| @@ -184,6 +174,17 @@ pub async fn launch( | |||
| 184 | statuses | 174 | statuses |
| 185 | }; | 175 | }; |
| 186 | 176 | ||
| 177 | // Fetch NIP-32 kind-1985 label events for all issues. | ||
| 178 | let label_events: Vec<nostr::Event> = get_events_from_local_cache( | ||
| 179 | git_repo_path, | ||
| 180 | vec![ | ||
| 181 | nostr::Filter::default() | ||
| 182 | .events(issues.iter().map(|e| e.id)) | ||
| 183 | .kind(KIND_LABEL), | ||
| 184 | ], | ||
| 185 | ) | ||
| 186 | .await?; | ||
| 187 | |||
| 187 | let comment_counts = get_comment_counts(git_repo_path, &issues).await?; | 188 | let comment_counts = get_comment_counts(git_repo_path, &issues).await?; |
| 188 | 189 | ||
| 189 | let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); | 190 | let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); |
| @@ -203,7 +204,7 @@ pub async fn launch( | |||
| 203 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { | 204 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { |
| 204 | return None; | 205 | return None; |
| 205 | } | 206 | } |
| 206 | let issue_labels = get_issue_labels(issue); | 207 | let issue_labels = get_labels(issue, &repo_ref, &label_events); |
| 207 | if !label_filter.is_empty() { | 208 | if !label_filter.is_empty() { |
| 208 | let issue_labels_lower: HashSet<String> = | 209 | let issue_labels_lower: HashSet<String> = |
| 209 | issue_labels.iter().map(|t| t.to_lowercase()).collect(); | 210 | issue_labels.iter().map(|t| t.to_lowercase()).collect(); |
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index 547c051..404b25e 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs | |||
| @@ -15,7 +15,8 @@ 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_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, | 18 | KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, |
| 19 | get_commit_id_from_patch, get_labels, | ||
| 19 | 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, |
| 20 | }, | 21 | }, |
| 21 | repo_ref::{RepoRef, is_grasp_server_in_list}, | 22 | repo_ref::{RepoRef, is_grasp_server_in_list}, |
| @@ -162,6 +163,17 @@ pub async fn launch( | |||
| 162 | statuses | 163 | statuses |
| 163 | }; | 164 | }; |
| 164 | 165 | ||
| 166 | // Fetch NIP-32 kind-1985 label events for all proposals. | ||
| 167 | let label_events: Vec<nostr::Event> = get_events_from_local_cache( | ||
| 168 | git_repo_path, | ||
| 169 | vec![ | ||
| 170 | nostr::Filter::default() | ||
| 171 | .events(proposals_and_revisions.iter().map(|e| e.id)) | ||
| 172 | .kind(KIND_LABEL), | ||
| 173 | ], | ||
| 174 | ) | ||
| 175 | .await?; | ||
| 176 | |||
| 165 | let mut open_proposals: Vec<&nostr::Event> = vec![]; | 177 | let mut open_proposals: Vec<&nostr::Event> = vec![]; |
| 166 | let mut draft_proposals: Vec<&nostr::Event> = vec![]; | 178 | let mut draft_proposals: Vec<&nostr::Event> = vec![]; |
| 167 | let mut closed_proposals: Vec<&nostr::Event> = vec![]; | 179 | let mut closed_proposals: Vec<&nostr::Event> = vec![]; |
| @@ -191,7 +203,7 @@ pub async fn launch( | |||
| 191 | // 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. |
| 192 | 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(); |
| 193 | 205 | ||
| 194 | let filtered_proposals: Vec<(&nostr::Event, Kind)> = proposals | 206 | let filtered_proposals: Vec<(&nostr::Event, Kind, Vec<String>)> = proposals |
| 195 | .iter() | 207 | .iter() |
| 196 | .filter_map(|p| { | 208 | .filter_map(|p| { |
| 197 | let status_kind = get_status(p, &repo_ref, &statuses, &proposals); | 209 | let status_kind = get_status(p, &repo_ref, &statuses, &proposals); |
| @@ -205,21 +217,15 @@ pub async fn launch( | |||
| 205 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { | 217 | if !status_filter.contains(status_str) && !status_filter.contains("unknown") { |
| 206 | return None; | 218 | return None; |
| 207 | } | 219 | } |
| 220 | let proposal_labels = get_labels(p, &repo_ref, &label_events); | ||
| 208 | if !label_filter.is_empty() { | 221 | if !label_filter.is_empty() { |
| 209 | let proposal_labels: HashSet<String> = p | 222 | let proposal_labels_lower: HashSet<String> = |
| 210 | .tags | 223 | proposal_labels.iter().map(|l| l.to_lowercase()).collect(); |
| 211 | .iter() | 224 | if !label_filter.iter().any(|l| proposal_labels_lower.contains(l)) { |
| 212 | .filter(|t| { | ||
| 213 | let s = t.as_slice(); | ||
| 214 | s.len() >= 2 && s[0].eq("t") | ||
| 215 | }) | ||
| 216 | .map(|t| t.as_slice()[1].to_lowercase()) | ||
| 217 | .collect(); | ||
| 218 | if !label_filter.iter().any(|l| proposal_labels.contains(l)) { | ||
| 219 | return None; | 225 | return None; |
| 220 | } | 226 | } |
| 221 | } | 227 | } |
| 222 | Some((p, status_kind)) | 228 | Some((p, status_kind, proposal_labels)) |
| 223 | }) | 229 | }) |
| 224 | .collect(); | 230 | .collect(); |
| 225 | 231 | ||
| @@ -241,7 +247,6 @@ pub async fn launch( | |||
| 241 | }; | 247 | }; |
| 242 | return show_proposal_details( | 248 | return show_proposal_details( |
| 243 | &filtered_proposals, | 249 | &filtered_proposals, |
| 244 | &repo_ref, | ||
| 245 | event_id_or_nevent, | 250 | event_id_or_nevent, |
| 246 | json, | 251 | json, |
| 247 | show_comments, | 252 | show_comments, |
| @@ -251,9 +256,9 @@ pub async fn launch( | |||
| 251 | } | 256 | } |
| 252 | 257 | ||
| 253 | if json { | 258 | if json { |
| 254 | output_json(&filtered_proposals, &repo_ref)?; | 259 | output_json(&filtered_proposals)?; |
| 255 | } else { | 260 | } else { |
| 256 | output_table(&filtered_proposals, &repo_ref, &status, &label_filter); | 261 | output_table(&filtered_proposals, &status, &label_filter); |
| 257 | } | 262 | } |
| 258 | 263 | ||
| 259 | Ok(()) | 264 | Ok(()) |
| @@ -317,8 +322,7 @@ fn status_kind_to_str(kind: Kind) -> &'static str { | |||
| 317 | } | 322 | } |
| 318 | 323 | ||
| 319 | fn output_table( | 324 | fn output_table( |
| 320 | proposals: &[(&nostr::Event, Kind)], | 325 | proposals: &[(&nostr::Event, Kind, Vec<String>)], |
| 321 | _repo_ref: &RepoRef, | ||
| 322 | status_filter: &str, | 326 | status_filter: &str, |
| 323 | label_filter: &HashSet<String>, | 327 | label_filter: &HashSet<String>, |
| 324 | ) { | 328 | ) { |
| @@ -328,7 +332,7 @@ fn output_table( | |||
| 328 | } | 332 | } |
| 329 | 333 | ||
| 330 | println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); | 334 | println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); |
| 331 | for (proposal, status_kind) in proposals { | 335 | for (proposal, status_kind, proposal_labels) in proposals { |
| 332 | let id = proposal.id.to_string(); | 336 | let id = proposal.id.to_string(); |
| 333 | let status = status_kind_to_str(*status_kind); | 337 | let status = status_kind_to_str(*status_kind); |
| 334 | let title = if let Ok(cl) = event_to_cover_letter(proposal) { | 338 | let title = if let Ok(cl) = event_to_cover_letter(proposal) { |
| @@ -338,14 +342,9 @@ fn output_table( | |||
| 338 | } else { | 342 | } else { |
| 339 | proposal.id.to_string() | 343 | proposal.id.to_string() |
| 340 | }; | 344 | }; |
| 341 | let labels_str: String = proposal | 345 | let labels_str: String = proposal_labels |
| 342 | .tags | ||
| 343 | .iter() | 346 | .iter() |
| 344 | .filter(|t| { | 347 | .map(|l| format!("#{l}")) |
| 345 | let s = t.as_slice(); | ||
| 346 | s.len() >= 2 && s[0].eq("t") | ||
| 347 | }) | ||
| 348 | .map(|t| format!("#{}", t.as_slice()[1])) | ||
| 349 | .collect::<Vec<_>>() | 348 | .collect::<Vec<_>>() |
| 350 | .join(" "); | 349 | .join(" "); |
| 351 | if labels_str.is_empty() { | 350 | if labels_str.is_empty() { |
| @@ -377,10 +376,10 @@ fn output_table( | |||
| 377 | ); | 376 | ); |
| 378 | } | 377 | } |
| 379 | 378 | ||
| 380 | fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Result<()> { | 379 | fn output_json(proposals: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { |
| 381 | let json_output: Vec<serde_json::Value> = proposals | 380 | let json_output: Vec<serde_json::Value> = proposals |
| 382 | .iter() | 381 | .iter() |
| 383 | .map(|(proposal, status_kind)| { | 382 | .map(|(proposal, status_kind, proposal_labels)| { |
| 384 | let id = proposal.id.to_string(); | 383 | let id = proposal.id.to_string(); |
| 385 | let status = status_kind_to_str(*status_kind).to_string(); | 384 | let status = status_kind_to_str(*status_kind).to_string(); |
| 386 | let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { | 385 | let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { |
| @@ -401,22 +400,13 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu | |||
| 401 | String::new(), | 400 | String::new(), |
| 402 | ) | 401 | ) |
| 403 | }; | 402 | }; |
| 404 | let labels: Vec<String> = proposal | ||
| 405 | .tags | ||
| 406 | .iter() | ||
| 407 | .filter(|t| { | ||
| 408 | let s = t.as_slice(); | ||
| 409 | s.len() >= 2 && s[0].eq("t") | ||
| 410 | }) | ||
| 411 | .map(|t| t.as_slice()[1].clone()) | ||
| 412 | .collect(); | ||
| 413 | serde_json::json!({ | 403 | serde_json::json!({ |
| 414 | "id": id, | 404 | "id": id, |
| 415 | "status": status, | 405 | "status": status, |
| 416 | "title": title, | 406 | "title": title, |
| 417 | "author": author, | 407 | "author": author, |
| 418 | "branch": branch, | 408 | "branch": branch, |
| 419 | "labels": labels, | 409 | "labels": proposal_labels, |
| 420 | }) | 410 | }) |
| 421 | }) | 411 | }) |
| 422 | .collect(); | 412 | .collect(); |
| @@ -454,8 +444,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> { | |||
| 454 | 444 | ||
| 455 | #[allow(clippy::too_many_lines)] | 445 | #[allow(clippy::too_many_lines)] |
| 456 | fn show_proposal_details( | 446 | fn show_proposal_details( |
| 457 | proposals: &[(&nostr::Event, Kind)], | 447 | proposals: &[(&nostr::Event, Kind, Vec<String>)], |
| 458 | _repo_ref: &RepoRef, | ||
| 459 | event_id_or_nevent: &str, | 448 | event_id_or_nevent: &str, |
| 460 | json: bool, | 449 | json: bool, |
| 461 | show_comments: bool, | 450 | show_comments: bool, |
| @@ -466,24 +455,14 @@ fn show_proposal_details( | |||
| 466 | 455 | ||
| 467 | let target_id = resolve_event_id(event_id_or_nevent)?; | 456 | let target_id = resolve_event_id(event_id_or_nevent)?; |
| 468 | 457 | ||
| 469 | let (proposal, status_kind) = proposals | 458 | let (proposal, status_kind, proposal_labels) = proposals |
| 470 | .iter() | 459 | .iter() |
| 471 | .find(|(p, _)| p.id == target_id) | 460 | .find(|(p, _, _)| p.id == target_id) |
| 472 | .context("proposal not found")?; | 461 | .context("proposal not found")?; |
| 473 | 462 | ||
| 474 | let cover_letter = event_to_cover_letter(proposal) | 463 | let cover_letter = event_to_cover_letter(proposal) |
| 475 | .context("failed to extract proposal details from proposal root event")?; | 464 | .context("failed to extract proposal details from proposal root event")?; |
| 476 | 465 | ||
| 477 | let proposal_labels: Vec<String> = proposal | ||
| 478 | .tags | ||
| 479 | .iter() | ||
| 480 | .filter(|t| { | ||
| 481 | let s = t.as_slice(); | ||
| 482 | s.len() >= 2 && s[0].eq("t") | ||
| 483 | }) | ||
| 484 | .map(|t| t.as_slice()[1].clone()) | ||
| 485 | .collect(); | ||
| 486 | |||
| 487 | if json { | 466 | if json { |
| 488 | let json_output = if show_comments { | 467 | let json_output = if show_comments { |
| 489 | let comments_json: Vec<serde_json::Value> = comments | 468 | let comments_json: Vec<serde_json::Value> = comments |
diff --git a/src/lib/client.rs b/src/lib/client.rs index 8501a1f..94a173f 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs | |||
| @@ -56,9 +56,9 @@ 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_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, | 59 | KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, |
| 60 | event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, | 60 | KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, |
| 61 | 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 | }, |
| 63 | login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, | 63 | login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, |
| 64 | repo_ref::{RepoRef, normalize_grasp_server_url}, | 64 | repo_ref::{RepoRef, normalize_grasp_server_url}, |
| @@ -1998,6 +1998,8 @@ async fn process_fetched_events( | |||
| 1998 | } | 1998 | } |
| 1999 | } else if event.kind.eq(&KIND_COMMENT) { | 1999 | } else if event.kind.eq(&KIND_COMMENT) { |
| 2000 | report.comments.insert(event.id); | 2000 | report.comments.insert(event.id); |
| 2001 | } else if event.kind.eq(&KIND_LABEL) { | ||
| 2002 | report.labels.insert(event.id); | ||
| 2001 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) | 2003 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) |
| 2002 | { | 2004 | { |
| 2003 | if request.missing_contributor_profiles.contains(&event.pubkey) { | 2005 | if request.missing_contributor_profiles.contains(&event.pubkey) { |
| @@ -2121,6 +2123,9 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo | |||
| 2121 | for c in relay_report.comments { | 2123 | for c in relay_report.comments { |
| 2122 | report.comments.insert(c); | 2124 | report.comments.insert(c); |
| 2123 | } | 2125 | } |
| 2126 | for c in relay_report.labels { | ||
| 2127 | report.labels.insert(c); | ||
| 2128 | } | ||
| 2124 | report.deletions += relay_report.deletions; | 2129 | report.deletions += relay_report.deletions; |
| 2125 | for c in relay_report.contributor_profiles { | 2130 | for c in relay_report.contributor_profiles { |
| 2126 | report.contributor_profiles.insert(c); | 2131 | report.contributor_profiles.insert(c); |
| @@ -2245,6 +2250,24 @@ pub fn get_fetch_filters( | |||
| 2245 | ] | 2250 | ] |
| 2246 | } | 2251 | } |
| 2247 | }, | 2252 | }, |
| 2253 | // Fetch NIP-32 kind-1985 label events for issues and proposals. | ||
| 2254 | // Label events reference the target via a lowercase `e` tag. | ||
| 2255 | { | ||
| 2256 | let all_root_ids: HashSet<EventId> = issue_ids | ||
| 2257 | .iter() | ||
| 2258 | .chain(proposal_ids.iter()) | ||
| 2259 | .copied() | ||
| 2260 | .collect(); | ||
| 2261 | if all_root_ids.is_empty() { | ||
| 2262 | vec![] | ||
| 2263 | } else { | ||
| 2264 | vec![ | ||
| 2265 | nostr::Filter::default() | ||
| 2266 | .events(all_root_ids) | ||
| 2267 | .kind(KIND_LABEL), | ||
| 2268 | ] | ||
| 2269 | } | ||
| 2270 | }, | ||
| 2248 | // Request kind-5 deletions for state events and repo announcements by | 2271 | // Request kind-5 deletions for state events and repo announcements by |
| 2249 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above | 2272 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above |
| 2250 | // covers addressable-event deletions; this covers the specific event IDs | 2273 | // covers addressable-event deletions; this covers the specific event IDs |
| @@ -2333,6 +2356,8 @@ pub struct FetchReport { | |||
| 2333 | issue_statuses: HashSet<EventId>, | 2356 | issue_statuses: HashSet<EventId>, |
| 2334 | /// NIP-22 kind-1111 comments against issues, patches, and PRs. | 2357 | /// NIP-22 kind-1111 comments against issues, patches, and PRs. |
| 2335 | comments: HashSet<EventId>, | 2358 | comments: HashSet<EventId>, |
| 2359 | /// NIP-32 kind-1985 label events for issues and proposals. | ||
| 2360 | labels: HashSet<EventId>, | ||
| 2336 | /// Count of kind-5 deletion events received (for display purposes). | 2361 | /// Count of kind-5 deletion events received (for display purposes). |
| 2337 | deletions: u32, | 2362 | deletions: u32, |
| 2338 | contributor_profiles: HashSet<PublicKey>, | 2363 | contributor_profiles: HashSet<PublicKey>, |
| @@ -2421,6 +2446,13 @@ impl Display for FetchReport { | |||
| 2421 | if self.comments.len() > 1 { "s" } else { "" }, | 2446 | if self.comments.len() > 1 { "s" } else { "" }, |
| 2422 | )); | 2447 | )); |
| 2423 | } | 2448 | } |
| 2449 | if !self.labels.is_empty() { | ||
| 2450 | display_items.push(format!( | ||
| 2451 | "{} label{}", | ||
| 2452 | self.labels.len(), | ||
| 2453 | if self.labels.len() > 1 { "s" } else { "" }, | ||
| 2454 | )); | ||
| 2455 | } | ||
| 2424 | if self.deletions > 0 { | 2456 | if self.deletions > 0 { |
| 2425 | display_items.push(format!( | 2457 | display_items.push(format!( |
| 2426 | "{} deletion{}", | 2458 | "{} deletion{}", |
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index 7c5dda2..a5aef12 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs | |||
| @@ -91,6 +91,9 @@ pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); | |||
| 91 | pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); | 91 | pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); |
| 92 | /// NIP-22 comment (kind 1111) — threaded comments on any event. | 92 | /// NIP-22 comment (kind 1111) — threaded comments on any event. |
| 93 | pub const KIND_COMMENT: Kind = Kind::Custom(1111); | 93 | pub const KIND_COMMENT: Kind = Kind::Custom(1111); |
| 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. | ||
| 96 | pub const KIND_LABEL: Kind = Kind::Custom(1985); | ||
| 94 | 97 | ||
| 95 | pub fn event_is_patch_set_root(event: &Event) -> bool { | 98 | pub fn event_is_patch_set_root(event: &Event) -> bool { |
| 96 | event.kind.eq(&Kind::GitPatch) | 99 | event.kind.eq(&Kind::GitPatch) |
| @@ -975,6 +978,91 @@ pub fn is_event_proposal_root_for_branch( | |||
| 975 | )) | 978 | )) |
| 976 | } | 979 | } |
| 977 | 980 | ||
| 981 | /// Compute the effective set of labels for `event`. | ||
| 982 | /// | ||
| 983 | /// Labels come from two sources, both subject to the same permission check: | ||
| 984 | /// | ||
| 985 | /// 1. `t` tags on the event itself (self-reported by the event author). | ||
| 986 | /// 2. NIP-32 kind-1985 label events in `all_label_events` that reference | ||
| 987 | /// `event` via a lowercase `e` tag and carry `["L", "#t"]` + | ||
| 988 | /// `["l", "<value>", "#t"]` tags. | ||
| 989 | /// | ||
| 990 | /// A label is only applied when the author of the source event (the original | ||
| 991 | /// event for inline `t` tags, or the kind-1985 event for external labels) is | ||
| 992 | /// either the author of `event` itself or one of the repository maintainers. | ||
| 993 | pub fn get_labels( | ||
| 994 | event: &Event, | ||
| 995 | repo_ref: &RepoRef, | ||
| 996 | all_label_events: &[Event], | ||
| 997 | ) -> Vec<String> { | ||
| 998 | let is_permitted = |pubkey: &PublicKey| -> bool { | ||
| 999 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | ||
| 1000 | }; | ||
| 1001 | |||
| 1002 | // 1. Inline `t` tags on the event itself — only if the event author is | ||
| 1003 | // permitted (they always are, since they authored the event, but we | ||
| 1004 | // keep the check symmetric with the external-label path). | ||
| 1005 | let mut labels: Vec<String> = if is_permitted(&event.pubkey) { | ||
| 1006 | event | ||
| 1007 | .tags | ||
| 1008 | .iter() | ||
| 1009 | .filter(|t| { | ||
| 1010 | let s = t.as_slice(); | ||
| 1011 | s.len() >= 2 && s[0].eq("t") | ||
| 1012 | }) | ||
| 1013 | .map(|t| t.as_slice()[1].clone()) | ||
| 1014 | .collect() | ||
| 1015 | } else { | ||
| 1016 | vec![] | ||
| 1017 | }; | ||
| 1018 | |||
| 1019 | // 2. External NIP-32 kind-1985 label events. | ||
| 1020 | // | ||
| 1021 | // A valid label event must: | ||
| 1022 | // - be kind 1985 | ||
| 1023 | // - reference `event` via a lowercase `e` tag | ||
| 1024 | // - have `["L", "#t"]` (namespace declaration) | ||
| 1025 | // - have at least one `["l", "<value>", "#t"]` tag | ||
| 1026 | // - be authored by a permitted pubkey | ||
| 1027 | let event_id_str = event.id.to_string(); | ||
| 1028 | for label_event in all_label_events { | ||
| 1029 | if !label_event.kind.eq(&KIND_LABEL) { | ||
| 1030 | continue; | ||
| 1031 | } | ||
| 1032 | if !is_permitted(&label_event.pubkey) { | ||
| 1033 | continue; | ||
| 1034 | } | ||
| 1035 | // Must reference our event via a lowercase `e` tag. | ||
| 1036 | let references_event = label_event.tags.iter().any(|t| { | ||
| 1037 | let s = t.as_slice(); | ||
| 1038 | s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) | ||
| 1039 | }); | ||
| 1040 | if !references_event { | ||
| 1041 | continue; | ||
| 1042 | } | ||
| 1043 | // Must declare the `#t` namespace. | ||
| 1044 | let has_namespace = label_event.tags.iter().any(|t| { | ||
| 1045 | let s = t.as_slice(); | ||
| 1046 | s.len() >= 2 && s[0].eq("L") && s[1].eq("#t") | ||
| 1047 | }); | ||
| 1048 | if !has_namespace { | ||
| 1049 | continue; | ||
| 1050 | } | ||
| 1051 | // Collect all `["l", "<value>", "#t"]` labels from this event. | ||
| 1052 | for tag in label_event.tags.iter() { | ||
| 1053 | let s = tag.as_slice(); | ||
| 1054 | if s.len() >= 3 && s[0].eq("l") && s[2].eq("#t") && !s[1].is_empty() { | ||
| 1055 | let label = s[1].clone(); | ||
| 1056 | if !labels.contains(&label) { | ||
| 1057 | labels.push(label); | ||
| 1058 | } | ||
| 1059 | } | ||
| 1060 | } | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | labels | ||
| 1064 | } | ||
| 1065 | |||
| 978 | pub fn get_status( | 1066 | pub fn get_status( |
| 979 | proposal: &Event, | 1067 | proposal: &Event, |
| 980 | repo_ref: &RepoRef, | 1068 | repo_ref: &RepoRef, |