upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 13:12:26 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 13:12:26 +0000
commita554da3ec0bdfef648921fda41f38ad0a5d53d27 (patch)
tree9e918f4c8baaa4ec2cbc20ed5bfcbab0e64c990f /src
parenta55d4150066456084fd18987acf014c18d0da976 (diff)
feat: fetch and report NIP-34 issues and their statuses
Download kind-1621 issues from relays into the local cache alongside patches and PRs. Issue IDs are tracked separately from proposal IDs throughout the fetch pipeline so they never appear in proposal lists. - get_fetch_filters: include Kind::GitIssue in the #a-tag filter and add a dedicated issue_ids parameter to fetch status events (1630-1633) tagged with known issue IDs - FetchRequest: add issue_ids field populated from the local cache - fetch_all_from_relay: track fresh_issue_roots independently of fresh_proposal_roots; loop continues until both are drained - process_fetched_events: route GitIssue events into fresh_issue_roots and report.issues; route status events to issue_statuses or statuses based on whether the root ID is a known issue or proposal - FetchReport: add issues and issue_statuses fields, consolidated across relays, and displayed as "N issue(s), M issue status(es)" in the report
Diffstat (limited to 'src')
-rw-r--r--src/lib/client.rs118
1 files changed, 101 insertions, 17 deletions
diff --git a/src/lib/client.rs b/src/lib/client.rs
index 41e5379..62db8d2 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -787,6 +787,7 @@ impl Connect for Client {
787 fresh_coordinates.insert(c); 787 fresh_coordinates.insert(c);
788 } 788 }
789 let mut fresh_proposal_roots = request.proposals.clone(); 789 let mut fresh_proposal_roots = request.proposals.clone();
790 let mut fresh_issue_roots = request.issue_ids.clone();
790 let mut fresh_profiles: HashSet<PublicKey> = request 791 let mut fresh_profiles: HashSet<PublicKey> = request
791 .missing_contributor_profiles 792 .missing_contributor_profiles
792 .union( 793 .union(
@@ -819,6 +820,7 @@ impl Connect for Client {
819 let filters = get_fetch_filters( 820 let filters = get_fetch_filters(
820 &fresh_coordinates, 821 &fresh_coordinates,
821 &fresh_proposal_roots, 822 &fresh_proposal_roots,
823 &fresh_issue_roots,
822 &fresh_non_proposal_event_ids, 824 &fresh_non_proposal_event_ids,
823 &fresh_profiles, 825 &fresh_profiles,
824 ); 826 );
@@ -842,6 +844,7 @@ impl Connect for Client {
842 844
843 fresh_coordinates = HashSet::new(); 845 fresh_coordinates = HashSet::new();
844 fresh_proposal_roots = HashSet::new(); 846 fresh_proposal_roots = HashSet::new();
847 fresh_issue_roots = HashSet::new();
845 fresh_profiles = HashSet::new(); 848 fresh_profiles = HashSet::new();
846 849
847 let relay = self.client.relay(&relay_url).await?; 850 let relay = self.client.relay(&relay_url).await?;
@@ -881,6 +884,7 @@ impl Connect for Client {
881 git_repo_path, 884 git_repo_path,
882 &mut fresh_coordinates, 885 &mut fresh_coordinates,
883 &mut fresh_proposal_roots, 886 &mut fresh_proposal_roots,
887 &mut fresh_issue_roots,
884 &mut fresh_profiles, 888 &mut fresh_profiles,
885 &mut report, 889 &mut report,
886 ) 890 )
@@ -888,6 +892,7 @@ impl Connect for Client {
888 892
889 if fresh_coordinates.is_empty() 893 if fresh_coordinates.is_empty()
890 && fresh_proposal_roots.is_empty() 894 && fresh_proposal_roots.is_empty()
895 && fresh_issue_roots.is_empty()
891 && fresh_profiles.is_empty() 896 && fresh_profiles.is_empty()
892 { 897 {
893 break; 898 break;
@@ -1630,6 +1635,7 @@ async fn create_relays_request(
1630 }; 1635 };
1631 1636
1632 let mut proposals: HashSet<EventId> = HashSet::new(); 1637 let mut proposals: HashSet<EventId> = HashSet::new();
1638 let mut issue_ids: HashSet<EventId> = HashSet::new();
1633 let mut missing_contributor_profiles: HashSet<PublicKey> = HashSet::new(); 1639 let mut missing_contributor_profiles: HashSet<PublicKey> = HashSet::new();
1634 let mut contributors: HashSet<PublicKey> = HashSet::new(); 1640 let mut contributors: HashSet<PublicKey> = HashSet::new();
1635 1641
@@ -1645,7 +1651,7 @@ async fn create_relays_request(
1645 git_repo_path, 1651 git_repo_path,
1646 vec![ 1652 vec![
1647 nostr::Filter::default() 1653 nostr::Filter::default()
1648 .kinds(vec![Kind::GitPatch, KIND_PULL_REQUEST]) 1654 .kinds(vec![Kind::GitPatch, KIND_PULL_REQUEST, Kind::GitIssue])
1649 .custom_tags( 1655 .custom_tags(
1650 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), 1656 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
1651 repo_coordinates_without_relays 1657 repo_coordinates_without_relays
@@ -1663,6 +1669,9 @@ async fn create_relays_request(
1663 { 1669 {
1664 proposals.insert(event.id); 1670 proposals.insert(event.id);
1665 contributors.insert(event.pubkey); 1671 contributors.insert(event.pubkey);
1672 } else if event.kind.eq(&Kind::GitIssue) {
1673 issue_ids.insert(event.id);
1674 contributors.insert(event.pubkey);
1666 } 1675 }
1667 } 1676 }
1668 } 1677 }
@@ -1739,6 +1748,7 @@ async fn create_relays_request(
1739 for filter in get_fetch_filters( 1748 for filter in get_fetch_filters(
1740 &repo_coordinates_without_relays, 1749 &repo_coordinates_without_relays,
1741 &proposals, 1750 &proposals,
1751 &issue_ids,
1742 &HashSet::new(), /* non_proposal_event_ids not yet computed; deletion events are not 1752 &HashSet::new(), /* non_proposal_event_ids not yet computed; deletion events are not
1743 * cached locally */ 1753 * cached locally */
1744 &missing_contributor_profiles 1754 &missing_contributor_profiles
@@ -1746,7 +1756,7 @@ async fn create_relays_request(
1746 &profiles_to_fetch_from_user_relays 1756 &profiles_to_fetch_from_user_relays
1747 .clone() 1757 .clone()
1748 .into_keys() 1758 .into_keys()
1749 .collect(), 1759 .collect::<HashSet<PublicKey>>(),
1750 ) 1760 )
1751 .copied() 1761 .copied()
1752 .collect(), 1762 .collect(),
@@ -1858,6 +1868,7 @@ async fn create_relays_request(
1858 ids 1868 ids
1859 }, 1869 },
1860 proposals, 1870 proposals,
1871 issue_ids,
1861 contributors, 1872 contributors,
1862 missing_contributor_profiles, 1873 missing_contributor_profiles,
1863 existing_events, 1874 existing_events,
@@ -1873,6 +1884,7 @@ async fn process_fetched_events(
1873 git_repo_path: Option<&Path>, 1884 git_repo_path: Option<&Path>,
1874 fresh_coordinates: &mut HashSet<Nip19Coordinate>, 1885 fresh_coordinates: &mut HashSet<Nip19Coordinate>,
1875 fresh_proposal_roots: &mut HashSet<EventId>, 1886 fresh_proposal_roots: &mut HashSet<EventId>,
1887 fresh_issue_roots: &mut HashSet<EventId>,
1876 fresh_profiles: &mut HashSet<PublicKey>, 1888 fresh_profiles: &mut HashSet<PublicKey>,
1877 report: &mut FetchReport, 1889 report: &mut FetchReport,
1878) -> Result<()> { 1890) -> Result<()> {
@@ -1976,6 +1988,14 @@ async fn process_fetched_events(
1976 { 1988 {
1977 fresh_profiles.insert(event.pubkey); 1989 fresh_profiles.insert(event.pubkey);
1978 } 1990 }
1991 } else if event.kind.eq(&Kind::GitIssue) {
1992 fresh_issue_roots.insert(event.id);
1993 report.issues.insert(event.id);
1994 if !request.contributors.contains(&event.pubkey)
1995 && !fresh_profiles.contains(&event.pubkey)
1996 {
1997 fresh_profiles.insert(event.pubkey);
1998 }
1979 } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) 1999 } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind)
1980 { 2000 {
1981 if request.missing_contributor_profiles.contains(&event.pubkey) { 2001 if request.missing_contributor_profiles.contains(&event.pubkey) {
@@ -2001,23 +2021,44 @@ async fn process_fetched_events(
2001 } 2021 }
2002 } 2022 }
2003 for event in &events { 2023 for event in &events {
2004 if !request.existing_events.contains(&event.id) 2024 if !request.existing_events.contains(&event.id) {
2005 && !event.tags.iter().any(|t| { 2025 let tagged_root_id = event.tags.iter().find_map(|t| {
2006 t.as_slice().len() > 1 2026 if t.as_slice().len() > 1
2007 && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e")) 2027 && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e"))
2008 && if let Ok(id) = EventId::parse(&t.as_slice()[1]) { 2028 {
2009 report.proposals.contains(&id) 2029 EventId::parse(&t.as_slice()[1]).ok()
2030 } else {
2031 None
2032 }
2033 });
2034 if status_kinds().contains(&event.kind) {
2035 // Route status events to the correct counter based on whether
2036 // the root event is a known issue or a proposal (patch/PR).
2037 // Don't double-count statuses that arrived in the same batch
2038 // as their parent (new issues/proposals already inflate the count).
2039 if let Some(root_id) = &tagged_root_id {
2040 if report.issues.contains(root_id) {
2041 // status for a new issue in this batch — skip (counted via issues)
2042 } else if report.proposals.contains(root_id) {
2043 // status for a new proposal in this batch — skip (counted via proposals)
2044 } else if request.issue_ids.contains(root_id) {
2045 report.issue_statuses.insert(event.id);
2010 } else { 2046 } else {
2011 false 2047 report.statuses.insert(event.id);
2012 } 2048 }
2013 }) 2049 }
2014 { 2050 } else {
2015 if (event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event)) 2051 // Non-status events: commits/PR-updates for proposals only.
2016 || event.kind.eq(&KIND_PULL_REQUEST_UPDATE) 2052 let not_tagged_with_new_proposal = tagged_root_id
2017 { 2053 .as_ref()
2018 report.commits.insert(event.id); 2054 .is_none_or(|id| !report.proposals.contains(id));
2019 } else if status_kinds().contains(&event.kind) { 2055 if not_tagged_with_new_proposal {
2020 report.statuses.insert(event.id); 2056 if (event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event))
2057 || event.kind.eq(&KIND_PULL_REQUEST_UPDATE)
2058 {
2059 report.commits.insert(event.id);
2060 }
2061 }
2021 } 2062 }
2022 } 2063 }
2023 } 2064 }
@@ -2070,6 +2111,12 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo
2070 for c in relay_report.statuses { 2111 for c in relay_report.statuses {
2071 report.statuses.insert(c); 2112 report.statuses.insert(c);
2072 } 2113 }
2114 for c in relay_report.issues {
2115 report.issues.insert(c);
2116 }
2117 for c in relay_report.issue_statuses {
2118 report.issue_statuses.insert(c);
2119 }
2073 report.deletions += relay_report.deletions; 2120 report.deletions += relay_report.deletions;
2074 for c in relay_report.contributor_profiles { 2121 for c in relay_report.contributor_profiles {
2075 report.contributor_profiles.insert(c); 2122 report.contributor_profiles.insert(c);
@@ -2107,6 +2154,7 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo
2107pub fn get_fetch_filters( 2154pub fn get_fetch_filters(
2108 repo_coordinates: &HashSet<Nip19Coordinate>, 2155 repo_coordinates: &HashSet<Nip19Coordinate>,
2109 proposal_ids: &HashSet<EventId>, 2156 proposal_ids: &HashSet<EventId>,
2157 issue_ids: &HashSet<EventId>,
2110 non_proposal_event_ids: &HashSet<EventId>, 2158 non_proposal_event_ids: &HashSet<EventId>,
2111 required_profiles: &HashSet<PublicKey>, 2159 required_profiles: &HashSet<PublicKey>,
2112) -> Vec<nostr::Filter> { 2160) -> Vec<nostr::Filter> {
@@ -2118,7 +2166,12 @@ pub fn get_fetch_filters(
2118 get_filter_state_events(repo_coordinates, false), 2166 get_filter_state_events(repo_coordinates, false),
2119 get_filter_repo_ann_events(repo_coordinates, false), 2167 get_filter_repo_ann_events(repo_coordinates, false),
2120 nostr::Filter::default() 2168 nostr::Filter::default()
2121 .kinds(vec![Kind::GitPatch, Kind::EventDeletion, KIND_PULL_REQUEST]) 2169 .kinds(vec![
2170 Kind::GitPatch,
2171 Kind::EventDeletion,
2172 KIND_PULL_REQUEST,
2173 Kind::GitIssue,
2174 ])
2122 .custom_tags( 2175 .custom_tags(
2123 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), 2176 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
2124 repo_coordinates 2177 repo_coordinates
@@ -2157,6 +2210,19 @@ pub fn get_fetch_filters(
2157 ), 2210 ),
2158 ] 2211 ]
2159 }, 2212 },
2213 // Fetch status events for known issues.
2214 if issue_ids.is_empty() {
2215 vec![]
2216 } else {
2217 vec![
2218 nostr::Filter::default()
2219 .events(issue_ids.clone())
2220 .kinds(status_kinds()),
2221 nostr::Filter::default()
2222 .custom_tags(SingleLetterTag::uppercase(Alphabet::E), issue_ids.clone())
2223 .kinds(status_kinds()),
2224 ]
2225 },
2160 // Request kind-5 deletions for state events and repo announcements by 2226 // Request kind-5 deletions for state events and repo announcements by
2161 // their event ID (#e tag), as per NIP-09. The #a-tagged filter above 2227 // their event ID (#e tag), as per NIP-09. The #a-tagged filter above
2162 // covers addressable-event deletions; this covers the specific event IDs 2228 // covers addressable-event deletions; this covers the specific event IDs
@@ -2241,6 +2307,8 @@ pub struct FetchReport {
2241 /// commits against existing propoals 2307 /// commits against existing propoals
2242 commits: HashSet<EventId>, 2308 commits: HashSet<EventId>,
2243 statuses: HashSet<EventId>, 2309 statuses: HashSet<EventId>,
2310 issues: HashSet<EventId>,
2311 issue_statuses: HashSet<EventId>,
2244 /// Count of kind-5 deletion events received (for display purposes). 2312 /// Count of kind-5 deletion events received (for display purposes).
2245 deletions: u32, 2313 deletions: u32,
2246 contributor_profiles: HashSet<PublicKey>, 2314 contributor_profiles: HashSet<PublicKey>,
@@ -2304,6 +2372,20 @@ impl Display for FetchReport {
2304 if self.statuses.len() > 1 { "es" } else { "" }, 2372 if self.statuses.len() > 1 { "es" } else { "" },
2305 )); 2373 ));
2306 } 2374 }
2375 if !self.issues.is_empty() {
2376 display_items.push(format!(
2377 "{} issue{}",
2378 self.issues.len(),
2379 if self.issues.len() > 1 { "s" } else { "" },
2380 ));
2381 }
2382 if !self.issue_statuses.is_empty() {
2383 display_items.push(format!(
2384 "{} issue status{}",
2385 self.issue_statuses.len(),
2386 if self.issue_statuses.len() > 1 { "es" } else { "" },
2387 ));
2388 }
2307 if self.deletions > 0 { 2389 if self.deletions > 0 {
2308 display_items.push(format!( 2390 display_items.push(format!(
2309 "{} deletion{}", 2391 "{} deletion{}",
@@ -2345,6 +2427,8 @@ pub struct FetchRequest {
2345 repo_coordinates_without_relays: Vec<(Nip19Coordinate, Option<Timestamp>)>, 2427 repo_coordinates_without_relays: Vec<(Nip19Coordinate, Option<Timestamp>)>,
2346 state: Option<(Timestamp, EventId)>, 2428 state: Option<(Timestamp, EventId)>,
2347 proposals: HashSet<EventId>, 2429 proposals: HashSet<EventId>,
2430 /// Known issue event IDs, used to fetch their status events.
2431 issue_ids: HashSet<EventId>,
2348 /// Event IDs of non-proposal events (state events, repo announcements) for 2432 /// Event IDs of non-proposal events (state events, repo announcements) for
2349 /// which we should also request kind-5 deletion events by `#e` tag. 2433 /// which we should also request kind-5 deletion events by `#e` tag.
2350 non_proposal_event_ids: HashSet<EventId>, 2434 non_proposal_event_ids: HashSet<EventId>,