diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 13:12:26 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 13:12:26 +0000 |
| commit | a554da3ec0bdfef648921fda41f38ad0a5d53d27 (patch) | |
| tree | 9e918f4c8baaa4ec2cbc20ed5bfcbab0e64c990f | |
| parent | a55d4150066456084fd18987acf014c18d0da976 (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
| -rw-r--r-- | src/lib/client.rs | 118 |
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 | |||
| 2107 | pub fn get_fetch_filters( | 2154 | pub 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>, |