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 /src/lib | |
| 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
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/client.rs | 38 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 88 |
2 files changed, 123 insertions, 3 deletions
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, |