chore: refactor backend around domain types #26
@@ -1,16 +1,23 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||||
///
|
///
|
||||||
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
/// Wraps the Rust Nostr SDK's [`Coordinate`], which parses, formats and hashes this.
|
||||||
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub type RepoAddr = Coordinate;
|
#[serde(transparent)]
|
||||||
|
pub struct RepoAddr(Coordinate);
|
||||||
|
|
||||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
impl RepoAddr {
|
||||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> Self {
|
||||||
}
|
Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn identifier_from_name(name: &str) -> String {
|
/// NIP-34 `d` tag identifier for a user-visible repository name.
|
||||||
|
pub fn identifier_from_name(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
if c.is_ascii_alphanumeric() || c == '/' {
|
if c.is_ascii_alphanumeric() || c == '/' {
|
||||||
@@ -20,4 +27,87 @@ pub fn identifier_from_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> Kind {
|
||||||
|
self.0.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.0.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identifier(&self) -> &str {
|
||||||
|
&self.0.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coordinate(&self) -> &Coordinate {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest announcement event for this repository.
|
||||||
|
pub fn announcement_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
|
.author(self.public_key())
|
||||||
|
.identifier(self.identifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest state event for this repository, carrying refs and HEAD.
|
||||||
|
pub fn state_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::RepoState)
|
||||||
|
.author(self.public_key())
|
||||||
|
.identifier(self.identifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All NIP-34 activity addressed to this repository via its `#a` tag.
|
||||||
|
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||||
|
/// The `a` tag is optional on status events per NIP-34.
|
||||||
|
/// Statuses published without it are not matched here.
|
||||||
|
pub fn activity_filter(&self) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||||
|
.coordinate(&self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletion events relevant to this repository.
|
||||||
|
///
|
||||||
|
/// Requests authored by the repository owner.
|
||||||
|
///
|
||||||
|
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||||
|
pub fn deletion_filters(&self) -> Vec<Filter> {
|
||||||
|
vec![
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
|
.author(self.public_key()),
|
||||||
|
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RepoAddr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt::Display::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for RepoAddr {
|
||||||
|
type Err = <Coordinate as FromStr>::Err;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Coordinate::from_str(s).map(RepoAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Coordinate> for RepoAddr {
|
||||||
|
fn from(coordinate: Coordinate) -> Self {
|
||||||
|
RepoAddr(coordinate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RepoAddr> for Coordinate {
|
||||||
|
fn from(addr: RepoAddr) -> Self {
|
||||||
|
addr.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::RepoAddr;
|
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
Kind::Comment,
|
Kind::Comment,
|
||||||
@@ -37,7 +35,7 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
/// Whether `kind` carries repository data: announcements, states, activity and deletions.
|
||||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||||
kind == Kind::GitRepoAnnouncement
|
kind == Kind::GitRepoAnnouncement
|
||||||
|| kind == Kind::RepoState
|
|| kind == Kind::RepoState
|
||||||
@@ -60,35 +58,15 @@ fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
|||||||
tag_value(event, name)?.parse::<Kind>().ok()
|
tag_value(event, name)?.parse::<Kind>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest announcement event for a repository.
|
/// Namespace for the repository-agnostic filter constructors.
|
||||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
pub struct Filters;
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::GitRepoAnnouncement)
|
|
||||||
.author(addr.public_key)
|
|
||||||
.identifier(addr.identifier.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Latest state event for a repository, carrying refs and HEAD.
|
impl Filters {
|
||||||
pub fn state(addr: &RepoAddr) -> Filter {
|
/// How far back deletion requests are fetched and stored.
|
||||||
Filter::new()
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
.kind(Kind::RepoState)
|
|
||||||
.author(addr.public_key)
|
|
||||||
.identifier(addr.identifier.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
/// The `a` tag is optional on status events per NIP-34.
|
|
||||||
/// Statuses published without it are not matched here.
|
|
||||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
|
||||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
|
||||||
///
|
|
||||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
|
||||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([
|
.kinds([
|
||||||
Kind::GitStatusOpen,
|
Kind::GitStatusOpen,
|
||||||
@@ -97,20 +75,20 @@ pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
|||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
])
|
])
|
||||||
.events(roots)
|
.events(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user's grasp list, kind `10317`.
|
/// A user's grasp list, kind `10317`.
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::GitUserGraspList)
|
.kind(Kind::GitUserGraspList)
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||||
/// The roots are issues, patches and PRs.
|
/// The roots are issues, patches and PRs.
|
||||||
///
|
///
|
||||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -123,107 +101,78 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
|||||||
.kind(Kind::Comment)
|
.kind(Kind::Comment)
|
||||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-22 comments on our issues, patches and pull requests.
|
/// NIP-22 comments on our issues, patches and pull requests.
|
||||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
fn notification_comments(me: PublicKey) -> Filter {
|
||||||
fn notification_comments(me: PublicKey) -> Filter {
|
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::Comment)
|
.kind(Kind::Comment)
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||||
vec![
|
vec![
|
||||||
notification_comments(me),
|
Self::notification_comments(me),
|
||||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Git activity authored by `me`, for "Continue where you left off".
|
/// Git activity authored by `me`, for "Continue where you left off".
|
||||||
///
|
///
|
||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
/// A comment on an unrelated kind matches too, so results must be filtered
|
||||||
/// through [`is_git_activity`] before display.
|
/// through `GitEvent::is_git_activity` before display.
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All repository announcements, for global discovery.
|
||||||
|
pub fn all_announcements() -> Filter {
|
||||||
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All repository state events, carrying each repository's refs and last push time.
|
||||||
|
pub fn all_states() -> Filter {
|
||||||
|
Filter::new().kind(Kind::RepoState)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `now` minus [`Self::DELETIONS_LOOKBACK`].
|
||||||
|
/// Quantized to whole days so identical filters hash the same.
|
||||||
|
///
|
||||||
|
/// This lets the backend's sync dedup match identical filters.
|
||||||
|
fn deletions_since() -> Timestamp {
|
||||||
|
let now = Timestamp::now().as_secs();
|
||||||
|
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All deletion-related events within [`Self::DELETIONS_LOOKBACK`].
|
||||||
|
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||||
|
///
|
||||||
|
/// Deletion requests must be known before any other event is shown.
|
||||||
|
pub fn deletions() -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
|
.since(Self::deletions_since())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
fn is_git_comment(event: &Event) -> bool {
|
pub(crate) fn is_git_comment(event: &Event) -> bool {
|
||||||
event.kind == Kind::Comment
|
event.kind == Kind::Comment
|
||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a status event references a git root, checked via its `k` tag.
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
fn is_git_status(event: &Event) -> bool {
|
pub(crate) fn is_git_status(event: &Event) -> bool {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `event` is git activity worth showing in the activity list.
|
|
||||||
pub fn is_git_activity(event: &Event) -> bool {
|
|
||||||
match event.kind {
|
|
||||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
|
||||||
Kind::Comment => is_git_comment(event),
|
|
||||||
Kind::GitStatusOpen
|
|
||||||
| Kind::GitStatusApplied
|
|
||||||
| Kind::GitStatusClosed
|
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All repository announcements, for global discovery.
|
|
||||||
pub fn all_announcements() -> Filter {
|
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All repository state events, carrying each repository's refs and last push time.
|
|
||||||
pub fn all_states() -> Filter {
|
|
||||||
Filter::new().kind(Kind::RepoState)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How far back deletion requests are fetched and stored.
|
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
|
||||||
|
|
||||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
|
||||||
/// Quantized to whole days so identical filters hash the same.
|
|
||||||
///
|
|
||||||
/// This lets the backend's sync dedup match identical filters.
|
|
||||||
fn deletions_since() -> Timestamp {
|
|
||||||
let now = Timestamp::now().as_secs();
|
|
||||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
|
||||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
|
||||||
///
|
|
||||||
/// Deletion requests must be known before any other event is shown.
|
|
||||||
pub fn deletions() -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
|
||||||
.since(deletions_since())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletion events relevant to a single repository.
|
|
||||||
///
|
|
||||||
/// Requests authored by the repository owner.
|
|
||||||
///
|
|
||||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
|
||||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
Filter::new()
|
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
|
||||||
.author(addr.public_key),
|
|
||||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::GitEvent;
|
||||||
|
|
||||||
fn keys(seed: u8) -> Keys {
|
fn keys(seed: u8) -> Keys {
|
||||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||||
@@ -252,14 +201,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
||||||
|
|
||||||
assert!(is_git_activity(&on_git));
|
assert!(on_git.is_git_activity());
|
||||||
assert!(is_git_activity(&on_repo));
|
assert!(on_repo.is_git_activity());
|
||||||
assert!(!is_git_activity(&on_note));
|
assert!(!on_note.is_git_activity());
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity());
|
||||||
&keys(1),
|
|
||||||
Kind::Comment,
|
|
||||||
Vec::new()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,12 +220,8 @@ mod tests {
|
|||||||
vec![kind_tag("k", Kind::Metadata)],
|
vec![kind_tag("k", Kind::Metadata)],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(status.is_git_activity());
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!unrelated.is_git_activity());
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity());
|
||||||
&keys(1),
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
Vec::new()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-127
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{RepoAddr, activity_subject};
|
use crate::{GitEvent, RepoAddr};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
@@ -38,7 +38,7 @@ impl InboxItem {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.or_else(|| self.own_events.first())
|
.or_else(|| self.own_events.first())
|
||||||
.or_else(|| self.events.first())
|
.or_else(|| self.events.first())
|
||||||
.map(activity_subject)
|
.map(|event| event.activity_subject())
|
||||||
.unwrap_or_else(|| "Untitled".to_string())
|
.unwrap_or_else(|| "Untitled".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,41 +112,149 @@ impl InboxItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root issue, patch or pull request of a notification event.
|
/// Resolves thread roots by following parent pointers through known events.
|
||||||
///
|
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||||
/// Returns `None` when the event is not git-related, or when its root is a
|
lookup: &'a L,
|
||||||
/// coordinate rather than an event.
|
}
|
||||||
///
|
|
||||||
/// - issue (1621) / PR (1618): itself
|
impl<'a, L> ThreadResolver<'a, L>
|
||||||
/// - patch (1617): its `e` parent patch, else itself
|
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
|
||||||
/// - PR update (1619): uppercase `E`
|
|
||||||
/// - statuses (1630-1633): NIP-10 root `e`
|
|
||||||
fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||||
{
|
{
|
||||||
|
pub fn new(lookup: &'a L) -> Self {
|
||||||
|
Self { lookup }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Root issue, patch or pull request of a notification event.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the event is not git-related, or when its root is a
|
||||||
|
/// coordinate rather than an event.
|
||||||
|
///
|
||||||
|
/// - issue (1621) / PR (1618): itself
|
||||||
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
|
/// - PR update (1619): uppercase `E`
|
||||||
|
/// - statuses (1630-1633): NIP-10 root `e`
|
||||||
|
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||||
Some(parent) => resolve_thread_root(parent, lookup),
|
Some(parent) => self.resolve_thread_root(parent),
|
||||||
None => event.id,
|
None => event.id,
|
||||||
}),
|
}),
|
||||||
Kind::Comment => match nip22::extract_root(event) {
|
Kind::Comment => match nip22::extract_root(event) {
|
||||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
Kind::GitPullRequestUpdate => {
|
Kind::GitPullRequestUpdate => self
|
||||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
.first_uppercase_e_id(event)
|
||||||
}
|
.map(|root| self.resolve_thread_root(root)),
|
||||||
Kind::GitStatusOpen
|
Kind::GitStatusOpen
|
||||||
| Kind::GitStatusApplied
|
| Kind::GitStatusApplied
|
||||||
| Kind::GitStatusClosed
|
| Kind::GitStatusClosed
|
||||||
| Kind::GitStatusDraft => {
|
| Kind::GitStatusDraft => self
|
||||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
.nip10_root_id(event)
|
||||||
}
|
.map(|root| self.resolve_thread_root(root)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||||
|
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut root = id;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !seen.insert(root) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(event) = (self.lookup)(root) else {
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.parent_id(&event) {
|
||||||
|
Some(parent) => root = parent,
|
||||||
|
None => return root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
||||||
|
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
for marker in ["reply", "root"] {
|
||||||
|
if let Some(id) = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != "e" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||||
|
|
||||||
|
if is_mention {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
}) {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.first_uppercase_e_id(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
||||||
|
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||||
|
.or_else(|| self.first_e_id(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First `e` tag id, in document order.
|
||||||
|
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
self.first_tag_id(event, "e")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First uppercase `E` tag id, in document order.
|
||||||
|
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||||
|
self.first_tag_id(event, "E")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
||||||
|
event.tags.iter().find_map(|tag| {
|
||||||
|
if tag.kind() != name {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event id from a four-element `e` tag carrying `marker`.
|
||||||
|
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tag.content()
|
||||||
|
.and_then(|content| EventId::from_hex(content).ok())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group notification events and the user's own events into one item per thread.
|
/// Group notification events and the user's own events into one item per thread.
|
||||||
@@ -162,12 +270,13 @@ where
|
|||||||
O: IntoIterator<Item = Event>,
|
O: IntoIterator<Item = Event>,
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event>,
|
||||||
{
|
{
|
||||||
|
let resolver = ThreadResolver::new(lookup);
|
||||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.pubkey == me {
|
if event.pubkey == me {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(root) = notification_root(&event, lookup) else {
|
let Some(root) = resolver.notification_root(&event) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
groups.entry(root).or_default().push(event);
|
groups.entry(root).or_default().push(event);
|
||||||
@@ -175,7 +284,7 @@ where
|
|||||||
|
|
||||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||||
for event in own {
|
for event in own {
|
||||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
||||||
own_groups.entry(root).or_default().push(event);
|
own_groups.entry(root).or_default().push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +306,8 @@ where
|
|||||||
root,
|
root,
|
||||||
address: root_event
|
address: root_event
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|event| event.tags.coordinates().next()),
|
.and_then(|event| event.tags.coordinates().next())
|
||||||
|
.map(RepoAddr::from),
|
||||||
root_event,
|
root_event,
|
||||||
events,
|
events,
|
||||||
own_events,
|
own_events,
|
||||||
@@ -321,103 +431,6 @@ fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
|||||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
|
||||||
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut root = id;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !seen.insert(root) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(event) = lookup(root) else {
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
|
|
||||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
match parent_id(&event) {
|
|
||||||
Some(parent) => root = parent,
|
|
||||||
None => return root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
|
||||||
fn parent_id(event: &Event) -> Option<EventId> {
|
|
||||||
for marker in ["reply", "root"] {
|
|
||||||
if let Some(id) = event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| e_tag_with_marker(tag, marker))
|
|
||||||
{
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != "e" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
|
||||||
|
|
||||||
if is_mention {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}) {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
first_uppercase_e_id(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
|
||||||
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
|
||||||
.or_else(|| first_e_id(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First `e` tag id, in document order.
|
|
||||||
fn first_e_id(event: &Event) -> Option<EventId> {
|
|
||||||
first_tag_id(event, "e")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First uppercase `E` tag id, in document order.
|
|
||||||
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
|
||||||
first_tag_id(event, "E")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
|
||||||
event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != name {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event id from a four-element `e` tag carrying `marker`.
|
|
||||||
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -480,7 +493,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let events = [issue.clone(), comment.clone()];
|
let events = [issue.clone(), comment.clone()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
notification_root(&comment, &lookup(&events)),
|
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||||
Some(issue.id)
|
Some(issue.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -491,7 +504,7 @@ mod tests {
|
|||||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||||
let events = [root_patch.clone(), child_patch.clone()];
|
let events = [root_patch.clone(), child_patch.clone()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
notification_root(&child_patch, &lookup(&events)),
|
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||||
Some(root_patch.id)
|
Some(root_patch.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -506,7 +519,10 @@ mod tests {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
let events = [issue.clone(), status.clone()];
|
let events = [issue.clone(), status.clone()];
|
||||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
assert_eq!(
|
||||||
|
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||||
|
Some(issue.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -515,7 +531,10 @@ mod tests {
|
|||||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||||
let events = [issue.clone(), reply, nested.clone()];
|
let events = [issue.clone(), reply, nested.clone()];
|
||||||
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
assert_eq!(
|
||||||
|
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||||
|
Some(issue.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ pub mod model;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::RepoAddr;
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use inbox::{InboxItem, InboxReadState};
|
pub use filters::Filters;
|
||||||
pub use model::{
|
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
pub use model::{Announcement, GitEvent, PullRequest};
|
||||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
pub use state::RepoState;
|
||||||
};
|
pub use status::RepoStatus;
|
||||||
pub use state::{build_state, parse_state};
|
|
||||||
pub use status::{RepoStatus, references_root, resolve_status};
|
|
||||||
|
|||||||
+195
-122
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::{RepoAddr, repo_addr};
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -49,7 +49,8 @@ impl Upstream {
|
|||||||
let addr = coordinate
|
let addr = coordinate
|
||||||
.parse::<Coordinate>()
|
.parse::<Coordinate>()
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
|
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||||
|
.map(RepoAddr::from);
|
||||||
Self {
|
Self {
|
||||||
raw: raw.to_owned(),
|
raw: raw.to_owned(),
|
||||||
addr,
|
addr,
|
||||||
@@ -64,10 +65,33 @@ impl Upstream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subject of a NIP-34 issue or pull request event.
|
/// Tag accessors for NIP-34 git collaboration events.
|
||||||
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
pub trait GitEvent {
|
||||||
pub fn activity_subject(event: &Event) -> String {
|
/// Subject of an issue or pull request event.
|
||||||
let subject = event
|
fn activity_subject(&self) -> String;
|
||||||
|
|
||||||
|
/// The `c` tag, the tip of the proposed branch, as hex.
|
||||||
|
fn current_commit(&self) -> Option<String>;
|
||||||
|
|
||||||
|
/// The `merge-base` tag, the base commit a pull request diffs against.
|
||||||
|
fn merge_base(&self) -> Option<String>;
|
||||||
|
|
||||||
|
/// The `clone` tag, URLs the tip commit can be fetched from.
|
||||||
|
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||||
|
|
||||||
|
/// The `branch-name` tag, the proposed branch's name.
|
||||||
|
fn branch_name(&self) -> Option<String>;
|
||||||
|
|
||||||
|
/// Whether the event is git activity worth showing in the activity list.
|
||||||
|
fn is_git_activity(&self) -> bool;
|
||||||
|
|
||||||
|
/// Whether the event carries an `e`/`E` tag pointing at `root`.
|
||||||
|
fn references_root(&self, root: &EventId) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitEvent for Event {
|
||||||
|
fn activity_subject(&self) -> String {
|
||||||
|
let subject = self
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
@@ -77,43 +101,131 @@ pub fn activity_subject(event: &Event) -> String {
|
|||||||
|
|
||||||
subject
|
subject
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
event
|
self.content
|
||||||
.content
|
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.find(|line| !line.is_empty())
|
.find(|line| !line.is_empty())
|
||||||
.map(|value| value.to_string())
|
.map(|value| value.to_string())
|
||||||
})
|
})
|
||||||
.unwrap_or("Untitled".to_string())
|
.unwrap_or("Untitled".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_commit(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_base(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_name(&self) -> Option<String> {
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_git_activity(&self) -> bool {
|
||||||
|
match self.kind {
|
||||||
|
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||||
|
Kind::Comment => crate::filters::is_git_comment(self),
|
||||||
|
Kind::GitStatusOpen
|
||||||
|
| Kind::GitStatusApplied
|
||||||
|
| Kind::GitStatusClosed
|
||||||
|
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn references_root(&self, root: &EventId) -> bool {
|
||||||
|
let root = root.to_hex();
|
||||||
|
self.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The patch set of a pull request.
|
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||||
///
|
fn activity_subject(&self) -> String {
|
||||||
/// Returns an empty list when no patch event can be linked to the PR.
|
(*self).activity_subject()
|
||||||
pub fn pull_request_patches<'a>(
|
}
|
||||||
pr: &Event,
|
|
||||||
patches: impl IntoIterator<Item = &'a Event>,
|
fn current_commit(&self) -> Option<String> {
|
||||||
) -> Vec<&'a Event> {
|
(*self).current_commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_base(&self) -> Option<String> {
|
||||||
|
(*self).merge_base()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||||
|
(*self).clone_urls()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_name(&self) -> Option<String> {
|
||||||
|
(*self).branch_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_git_activity(&self) -> bool {
|
||||||
|
(*self).is_git_activity()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn references_root(&self, root: &EventId) -> bool {
|
||||||
|
(*self).references_root(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-34 pull request root event, with its patch set and update history.
|
||||||
|
pub struct PullRequest<'a>(pub &'a Event);
|
||||||
|
|
||||||
|
impl<'a> PullRequest<'a> {
|
||||||
|
pub fn new(event: &'a Event) -> Self {
|
||||||
|
Self(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The patch set of the pull request.
|
||||||
|
///
|
||||||
|
/// Returns an empty list when no patch event can be linked to the PR.
|
||||||
|
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||||
|
let pr = self.0;
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
|
|
||||||
// The PR references its root patch via an `e` tag.
|
// The PR references its root patch via an `e` tag.
|
||||||
// Follow the NIP-10 reply chain forward from there.
|
|
||||||
// Each patch replies to the previous one, and among several replies the newest wins.
|
|
||||||
if let Some(root_id) = pr.tags.event_ids().next()
|
if let Some(root_id) = pr.tags.event_ids().next()
|
||||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||||
{
|
{
|
||||||
return forward_series(root, &patches);
|
return Self::forward_series(root, &patches);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The PR has no `e` tag.
|
// The PR has no `e` tag.
|
||||||
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
||||||
// Walk the reply chain backward to the root.
|
let Some(tip) = pr.current_commit() else {
|
||||||
let Some(tip) = current_commit_of(pr) else {
|
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let Some(last) = patches
|
let Some(last) = patches
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|patch| patch_produces_commit(patch, &tip))
|
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||||
.max_by_key(|patch| patch.created_at)
|
.max_by_key(|patch| patch.created_at)
|
||||||
.copied()
|
.copied()
|
||||||
else {
|
else {
|
||||||
@@ -136,11 +248,15 @@ pub fn pull_request_patches<'a>(
|
|||||||
}
|
}
|
||||||
series.reverse();
|
series.reverse();
|
||||||
series
|
series
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
/// The patch set of the pull request joined into one diff text.
|
||||||
|
///
|
||||||
|
/// Falls back to the root event's content when no patch set is found.
|
||||||
|
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||||
|
let pr = self.0;
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
let series = pull_request_patches(pr, patches.iter().copied());
|
let series = self.patches(patches.iter().copied());
|
||||||
if series.is_empty() {
|
if series.is_empty() {
|
||||||
return pr.content.clone();
|
return pr.content.clone();
|
||||||
}
|
}
|
||||||
@@ -149,10 +265,30 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
|
|||||||
.map(|patch| patch.content.as_str())
|
.map(|patch| patch.content.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author.
|
||||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
///
|
||||||
|
/// A pull request's tip is only mutable by its author per NIP-34, updates
|
||||||
|
/// from anyone else are ignored even if they are newer.
|
||||||
|
pub fn latest_update(
|
||||||
|
events: impl Iterator<Item = &'a Event>,
|
||||||
|
root: &Event,
|
||||||
|
) -> Option<&'a Event> {
|
||||||
|
let root_hex = root.id.to_hex();
|
||||||
|
events
|
||||||
|
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||||
|
.filter(|e| e.pubkey == root.pubkey)
|
||||||
|
.filter(|e| {
|
||||||
|
e.tags
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||||
|
})
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||||
|
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||||
let mut series = vec![root];
|
let mut series = vec![root];
|
||||||
loop {
|
loop {
|
||||||
let next = patches
|
let next = patches
|
||||||
@@ -171,81 +307,32 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
|
|||||||
series.push(next);
|
series.push(next);
|
||||||
}
|
}
|
||||||
series
|
series
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||||
pub fn current_commit_of(event: &Event) -> Option<String> {
|
///
|
||||||
event
|
/// It lets clients find existing patches for a specific commit.
|
||||||
|
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||||
|
patch
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||||
_ => None,
|
_ => false,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
|
impl Announcement {
|
||||||
pub fn merge_base_of(event: &Event) -> Option<String> {
|
/// The announced forks of `base` a new pull request compare can be built from.
|
||||||
event
|
///
|
||||||
.tags
|
/// The user's own forks are listed first.
|
||||||
.iter()
|
pub fn forks_in<'a>(
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `clone` tag of an event, URLs the tip commit can be fetched from.
|
|
||||||
pub fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `branch-name` tag of an event, the proposed branch's name.
|
|
||||||
pub fn branch_name_of(event: &Event) -> Option<String> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author.
|
|
||||||
///
|
|
||||||
/// A pull request's tip is only mutable by its author, per NIP-34; updates
|
|
||||||
/// from anyone else are ignored even if they are newer.
|
|
||||||
pub fn latest_update<'a>(
|
|
||||||
events: impl Iterator<Item = &'a Event>,
|
|
||||||
root: &Event,
|
|
||||||
) -> Option<&'a Event> {
|
|
||||||
let root_hex = root.id.to_hex();
|
|
||||||
events
|
|
||||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
|
||||||
.filter(|e| e.pubkey == root.pubkey)
|
|
||||||
.filter(|e| {
|
|
||||||
e.tags
|
|
||||||
.iter()
|
|
||||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
|
||||||
})
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The announced forks of `base` a new pull request compare can be built from.
|
|
||||||
///
|
|
||||||
/// The user's own forks are listed first.
|
|
||||||
pub fn fork_candidates<'a>(
|
|
||||||
announcements: &'a [Announcement],
|
announcements: &'a [Announcement],
|
||||||
base: &RepoAddr,
|
base: &RepoAddr,
|
||||||
base_euc: Option<&str>,
|
base_euc: Option<&str>,
|
||||||
user: Option<PublicKey>,
|
user: Option<PublicKey>,
|
||||||
) -> Vec<&'a Announcement> {
|
) -> Vec<&'a Announcement> {
|
||||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
let (mut own, mut others) = (Vec::new(), Vec::new());
|
||||||
for announcement in announcements {
|
for announcement in announcements {
|
||||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
||||||
@@ -258,22 +345,8 @@ pub fn fork_candidates<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
own.into_iter().chain(others).collect()
|
own.into_iter().chain(others).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
|
||||||
///
|
|
||||||
/// It lets clients find existing patches for a specific commit.
|
|
||||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
|
||||||
patch
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
|
||||||
_ => false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Announcement {
|
|
||||||
/// Parse a kind `30617` event.
|
/// Parse a kind `30617` event.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||||
@@ -337,7 +410,7 @@ impl Announcement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn addr(&self) -> RepoAddr {
|
pub fn addr(&self) -> RepoAddr {
|
||||||
repo_addr(self.owner, self.id.clone())
|
RepoAddr::new(self.owner, self.id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The name of the repository, or a default if none is provided.
|
/// The name of the repository, or a default if none is provided.
|
||||||
@@ -499,7 +572,7 @@ mod tests {
|
|||||||
// The raw value keeps the `|git-url` suffix.
|
// The raw value keeps the `|git-url` suffix.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
upstream.addr,
|
upstream.addr,
|
||||||
Some(crate::repo_addr(
|
Some(RepoAddr::new(
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream"
|
"upstream"
|
||||||
))
|
))
|
||||||
@@ -517,7 +590,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||||
// The base repository, announced by the `u` tag's owner.
|
// The base repository, announced by the `u` tag's owner.
|
||||||
let base = crate::repo_addr(
|
let base = RepoAddr::new(
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
@@ -559,7 +632,7 @@ mod tests {
|
|||||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||||
// A permanent fork re-announces its EUC, the first commit after the fork.
|
// A permanent fork re-announces its EUC, the first commit after the fork.
|
||||||
// Only the `u` tag still relates it to the base.
|
// Only the `u` tag still relates it to the base.
|
||||||
let base = crate::repo_addr(
|
let base = RepoAddr::new(
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
@@ -632,11 +705,11 @@ mod tests {
|
|||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pull_request_patch(&pr, [&root, &second]),
|
PullRequest::new(&pr).patch([&root, &second]),
|
||||||
"patch-one\npatch-two"
|
"patch-one\npatch-two"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pull_request_patches(&pr, [&root, &second]),
|
PullRequest::new(&pr).patches([&root, &second]),
|
||||||
vec![&root, &second]
|
vec![&root, &second]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -648,7 +721,7 @@ mod tests {
|
|||||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
let series = pull_request_patches(&pr, [&third, &root, &second]);
|
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
series
|
series
|
||||||
.iter()
|
.iter()
|
||||||
@@ -677,7 +750,7 @@ mod tests {
|
|||||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||||
);
|
);
|
||||||
|
|
||||||
let series = pull_request_patches(&pr, [&root, &last]);
|
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
series
|
series
|
||||||
.iter()
|
.iter()
|
||||||
@@ -729,7 +802,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||||
let latest = latest_update(events.iter(), &root).expect("an update");
|
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||||
|
|
||||||
assert_eq!(latest.created_at.as_secs(), 300);
|
assert_eq!(latest.created_at.as_secs(), 300);
|
||||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||||
@@ -751,7 +824,7 @@ mod tests {
|
|||||||
|
|
||||||
// The tip of a PR is only mutable by its author.
|
// The tip of a PR is only mutable by its author.
|
||||||
// A newer update from anyone else must not win.
|
// A newer update from anyone else must not win.
|
||||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
const OWNER_KEYS: [&str; 3] = [
|
const OWNER_KEYS: [&str; 3] = [
|
||||||
@@ -784,7 +857,7 @@ mod tests {
|
|||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
let clone = "https://grasp.example/npub1x/my-fork.git";
|
let clone = "https://grasp.example/npub1x/my-fork.git";
|
||||||
|
|
||||||
let base_addr = crate::repo_addr(
|
let base_addr = RepoAddr::new(
|
||||||
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
||||||
"upstream",
|
"upstream",
|
||||||
);
|
);
|
||||||
@@ -819,7 +892,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
||||||
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user));
|
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user));
|
||||||
|
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||||
@@ -829,7 +902,7 @@ mod tests {
|
|||||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
||||||
let base_addr = crate::repo_addr(base_owner, "upstream");
|
let base_addr = RepoAddr::new(base_owner, "upstream");
|
||||||
|
|
||||||
let mut all = vec![
|
let mut all = vec![
|
||||||
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
||||||
@@ -859,7 +932,7 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
|
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
|
||||||
assert_eq!(forks.len(), 1);
|
assert_eq!(forks.len(), 1);
|
||||||
assert_eq!(forks[0].id, "mirror");
|
assert_eq!(forks[0].id, "mirror");
|
||||||
|
|
||||||
@@ -876,7 +949,7 @@ mod tests {
|
|||||||
.pop()
|
.pop()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let forks = fork_candidates(&all, &base_addr, None, Some(base_owner));
|
let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner));
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["u-fork"]);
|
assert_eq!(ids, vec!["u-fork"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
/// Build a kind `30618` repository state event from refs and HEAD,
|
/// Refs and HEAD parsed from a kind `30618` repository state event.
|
||||||
/// it is published as `ref: refs/heads/<branch>`.
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
///
|
pub struct RepoState {
|
||||||
/// The `d` tag matches the repository id.
|
/// `(refname, commit-id)` pairs.
|
||||||
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
pub refs: Vec<(String, String)>,
|
||||||
|
/// Branch pointed to by the `HEAD` tag, if any.
|
||||||
|
pub head: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoState {
|
||||||
|
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||||
|
/// it is published as `ref: refs/heads/<branch>`.
|
||||||
|
///
|
||||||
|
/// The `d` tag matches the repository id.
|
||||||
|
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||||
for (name, commit) in refs {
|
for (name, commit) in refs {
|
||||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||||
@@ -15,13 +25,10 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||||
///
|
pub fn parse(event: &Event) -> Self {
|
||||||
/// `refs` are `(refname, commit-id)` pairs.
|
|
||||||
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
|
||||||
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
|
||||||
let mut refs = Vec::new();
|
let mut refs = Vec::new();
|
||||||
let mut head = None;
|
let mut head = None;
|
||||||
|
|
||||||
@@ -38,7 +45,8 @@ pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(refs, head)
|
Self { refs, head }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -76,11 +84,11 @@ mod tests {
|
|||||||
&["refs/tags/v1.0", COMMIT_A],
|
&["refs/tags/v1.0", COMMIT_A],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let (refs, head) = parse_state(&event);
|
let state = RepoState::parse(&event);
|
||||||
|
|
||||||
assert_eq!(head.as_deref(), Some("main"));
|
assert_eq!(state.head.as_deref(), Some("main"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
refs,
|
state.refs,
|
||||||
vec![
|
vec![
|
||||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
@@ -90,22 +98,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_state_round_trips_through_parse() {
|
fn build_round_trips_through_parse() {
|
||||||
let refs = [
|
let refs = [
|
||||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||||
];
|
];
|
||||||
|
|
||||||
let event = build_state("my-repo", &refs, Some("main"))
|
let event = RepoState::build("my-repo", &refs, Some("main"))
|
||||||
.finalize(&keys())
|
.finalize(&keys())
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
assert_eq!(event.kind, Kind::RepoState);
|
assert_eq!(event.kind, Kind::RepoState);
|
||||||
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||||
|
|
||||||
let (parsed_refs, head) = parse_state(&event);
|
let state = RepoState::parse(&event);
|
||||||
assert_eq!(parsed_refs, refs);
|
assert_eq!(state.refs, refs);
|
||||||
assert_eq!(head.as_deref(), Some("main"));
|
assert_eq!(state.head.as_deref(), Some("main"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ impl RepoStatus {
|
|||||||
Self::Draft => Kind::GitStatusDraft,
|
Self::Draft => Kind::GitStatusDraft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the status of a root event per NIP-34.
|
||||||
|
///
|
||||||
|
/// Defaults to [`RepoStatus::Open`].
|
||||||
|
pub fn resolve<'a, I>(
|
||||||
|
status_events: I,
|
||||||
|
root_author: &PublicKey,
|
||||||
|
maintainers: &[PublicKey],
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = &'a Event>,
|
||||||
|
{
|
||||||
|
status_events
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
||||||
|
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||||
|
.unwrap_or(RepoStatus::Open)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||||
@@ -41,26 +61,6 @@ pub fn references_root(event: &Event, root: &EventId) -> bool {
|
|||||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event per NIP-34.
|
|
||||||
///
|
|
||||||
/// Defaults to [`RepoStatus::Open`].
|
|
||||||
pub fn resolve_status<'a, I>(
|
|
||||||
status_events: I,
|
|
||||||
root_author: &PublicKey,
|
|
||||||
maintainers: &[PublicKey],
|
|
||||||
) -> RepoStatus
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = &'a Event>,
|
|
||||||
{
|
|
||||||
status_events
|
|
||||||
.into_iter()
|
|
||||||
.filter(|e| RepoStatus::from_kind(e.kind).is_some())
|
|
||||||
.filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey))
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
|
||||||
.unwrap_or(RepoStatus::Open)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -115,7 +115,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_status(
|
RepoStatus::resolve(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
@@ -140,7 +140,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_status(
|
RepoStatus::resolve(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ impl GitCache {
|
|||||||
|
|
||||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||||
self.root
|
self.root
|
||||||
.join(addr.public_key.to_hex())
|
.join(addr.public_key().to_hex())
|
||||||
.join(sanitize_path_component(&addr.identifier))
|
.join(sanitize_path_component(addr.identifier()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use nostr::nips::nip19::Nip19Coordinate;
|
|||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::client::SyncSummary;
|
use nostr_sdk::client::SyncSummary;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr};
|
use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters};
|
||||||
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||||
|
|
||||||
use crate::git_store::repo_mirror_path;
|
use crate::git_store::repo_mirror_path;
|
||||||
@@ -388,7 +388,7 @@ impl Backend {
|
|||||||
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
||||||
};
|
};
|
||||||
|
|
||||||
let repo_id = identifier_from_name(&name);
|
let repo_id = RepoAddr::identifier_from_name(&name);
|
||||||
|
|
||||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||||
return Task::ready(Err(anyhow!(
|
return Task::ready(Err(anyhow!(
|
||||||
@@ -563,7 +563,7 @@ impl Backend {
|
|||||||
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
||||||
};
|
};
|
||||||
|
|
||||||
let repo_id = identifier_from_name(&name);
|
let repo_id = RepoAddr::identifier_from_name(&name);
|
||||||
|
|
||||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||||
return Task::ready(Err(anyhow!(
|
return Task::ready(Err(anyhow!(
|
||||||
@@ -694,8 +694,8 @@ impl Backend {
|
|||||||
|
|
||||||
// Record the ngit-compatible `nostr.repo` marker,
|
// Record the ngit-compatible `nostr.repo` marker,
|
||||||
// so the next scan detects the repository instead of offering to publish it again.
|
// so the next scan detects the repository instead of offering to publish it again.
|
||||||
let coordinate = repo_addr(event.pubkey, repo_id.clone());
|
let coordinate = RepoAddr::new(event.pubkey, repo_id.clone());
|
||||||
match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() {
|
match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() {
|
||||||
Ok(naddr) => {
|
Ok(naddr) => {
|
||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
@@ -876,7 +876,7 @@ impl Backend {
|
|||||||
let Some(public_key) = self.current_user else {
|
let Some(public_key) = self.current_user else {
|
||||||
return Task::ready(Err(anyhow!("Sign in to delete a repository")));
|
return Task::ready(Err(anyhow!("Sign in to delete a repository")));
|
||||||
};
|
};
|
||||||
if public_key != addr.public_key {
|
if public_key != addr.public_key() {
|
||||||
return Task::ready(Err(anyhow!("Only the repository owner can delete it")));
|
return Task::ready(Err(anyhow!("Only the repository owner can delete it")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,9 +888,9 @@ impl Backend {
|
|||||||
let db = client.database();
|
let db = client.database();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for filter in [
|
for filter in [
|
||||||
filters::announcement(&addr),
|
addr.announcement_filter(),
|
||||||
filters::state(&addr),
|
addr.state_filter(),
|
||||||
filters::activity(&addr),
|
addr.activity_filter(),
|
||||||
] {
|
] {
|
||||||
events.extend(db.query(filter).await?);
|
events.extend(db.query(filter).await?);
|
||||||
}
|
}
|
||||||
@@ -914,7 +914,7 @@ impl Backend {
|
|||||||
let result = async {
|
let result = async {
|
||||||
sync_bootstrap_only(
|
sync_bootstrap_only(
|
||||||
&client,
|
&client,
|
||||||
filters::grasp_list(public_key),
|
Filters::grasp_list(public_key),
|
||||||
SyncOptions::default(),
|
SyncOptions::default(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -961,8 +961,8 @@ impl Backend {
|
|||||||
let me = self.current_user;
|
let me = self.current_user;
|
||||||
|
|
||||||
if let Some(me) = me {
|
if let Some(me) = me {
|
||||||
self.subscribe_bootstrap(filters::notifications(me), cx);
|
self.subscribe_bootstrap(Filters::notifications(me), cx);
|
||||||
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
|
self.subscribe_bootstrap(vec![Filters::authored_activity(me)], cx);
|
||||||
|
|
||||||
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
|
||||||
.read(cx)
|
.read(cx)
|
||||||
@@ -973,8 +973,8 @@ impl Backend {
|
|||||||
|
|
||||||
if !relays.is_empty() {
|
if !relays.is_empty() {
|
||||||
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
let relays: Vec<RelayUrl> = relays.into_iter().collect();
|
||||||
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
|
self.connect_repo_relays(relays.clone(), Filters::notifications(me), cx);
|
||||||
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
|
self.connect_repo_relays(relays, vec![Filters::authored_activity(me)], cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,7 +1357,7 @@ pub async fn user_grasp_list_servers(
|
|||||||
) -> Result<Vec<RelayUrl>, Error> {
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let events: Vec<Event> = client
|
let events: Vec<Event> = client
|
||||||
.database()
|
.database()
|
||||||
.query(filters::grasp_list(user))
|
.query(Filters::grasp_list(user))
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1517,7 +1517,7 @@ async fn sign_state_event(
|
|||||||
last_created_at + 1
|
last_created_at + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
let event = build_state(repo_id, refs, head)
|
let event = RepoState::build(repo_id, refs, head)
|
||||||
.custom_created_at(Timestamp::from_secs(created_at))
|
.custom_created_at(Timestamp::from_secs(created_at))
|
||||||
.finalize_async(signer)
|
.finalize_async(signer)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{AppContext, Context, Task};
|
use gpui::{AppContext, Context, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
use signed_core::{Deletions, Filters, InboxItem, InboxReadState, GitEvent, inbox};
|
||||||
|
|
||||||
use crate::backend::Backend;
|
use crate::backend::Backend;
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ pub async fn query_inbox(
|
|||||||
me: PublicKey,
|
me: PublicKey,
|
||||||
state: &InboxReadState,
|
state: &InboxReadState,
|
||||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||||
@@ -105,10 +105,10 @@ pub async fn query_inbox(
|
|||||||
let mut activity = Vec::new();
|
let mut activity = Vec::new();
|
||||||
for event in client
|
for event in client
|
||||||
.database()
|
.database()
|
||||||
.query(filters::authored_activity(me))
|
.query(Filters::authored_activity(me))
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
if deletions.is_deleted(&event) || !event.is_git_activity() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||||
@@ -169,7 +169,7 @@ async fn fetch_notifications(
|
|||||||
let mut notifications: Vec<Event> = Vec::new();
|
let mut notifications: Vec<Event> = Vec::new();
|
||||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||||
|
|
||||||
for filter in filters::notifications(me) {
|
for filter in Filters::notifications(me) {
|
||||||
for event in client.database().query(filter).await? {
|
for event in client.database().query(filter).await? {
|
||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||||
use signed_core::{Announcement, RepoAddr, repo_addr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
||||||
|
|
||||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||||
@@ -113,7 +113,7 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
|||||||
let owner = binding.owner?;
|
let owner = binding.owner?;
|
||||||
let identifier = binding.identifier.as_deref()?;
|
let identifier = binding.identifier.as_deref()?;
|
||||||
|
|
||||||
Some(repo_addr(owner, identifier))
|
Some(RepoAddr::new(owner, identifier))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A scanned repository resolved against the known announcements.
|
/// A scanned repository resolved against the known announcements.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use nostr::event::IntoEventBuilder;
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::{EventFetchingStrategy, SettingsStore};
|
use settings::{EventFetchingStrategy, SettingsStore};
|
||||||
use signed_core::{
|
use signed_core::{
|
||||||
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches,
|
Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus,
|
||||||
|
filters,
|
||||||
};
|
};
|
||||||
use signed_git::Nip34Binding;
|
use signed_git::Nip34Binding;
|
||||||
use signed_nostr::UniversalSigner;
|
use signed_nostr::UniversalSigner;
|
||||||
@@ -210,8 +211,8 @@ impl RepoStore {
|
|||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
let coordinate = update.coordinate.as_ref() == Some(addr);
|
let coordinate = update.coordinate.as_ref() == Some(addr.coordinate());
|
||||||
let author = update.author == addr.public_key;
|
let author = update.author == addr.public_key();
|
||||||
|
|
||||||
let authored = (update.kind == Kind::GitRepoAnnouncement
|
let authored = (update.kind == Kind::GitRepoAnnouncement
|
||||||
|| update.kind == Kind::RepoState)
|
|| update.kind == Kind::RepoState)
|
||||||
@@ -251,12 +252,12 @@ impl RepoStore {
|
|||||||
let mut filters = vec![
|
let mut filters = vec![
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
.author(addr.public_key)
|
.author(addr.public_key())
|
||||||
.identifier(addr.identifier.clone()),
|
.identifier(addr.identifier()),
|
||||||
filters::activity(addr),
|
addr.activity_filter(),
|
||||||
];
|
];
|
||||||
// Deletion requests, NIP-09/62, must be known before any event is shown.
|
// Deletion requests, NIP-09/62, must be known before any event is shown.
|
||||||
filters.extend(filters::deletions_for_repo(addr));
|
filters.extend(addr.deletion_filters());
|
||||||
filters
|
filters
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +291,8 @@ impl RepoStore {
|
|||||||
let mut pubkeys = maintainers.to_vec();
|
let mut pubkeys = maintainers.to_vec();
|
||||||
// NIP-34 events tag the announcement author,
|
// NIP-34 events tag the announcement author,
|
||||||
// which may not be a maintainer for subordinate forks.
|
// which may not be a maintainer for subordinate forks.
|
||||||
if !pubkeys.contains(&addr.public_key) {
|
if !pubkeys.contains(&addr.public_key()) {
|
||||||
pubkeys.push(addr.public_key);
|
pubkeys.push(addr.public_key());
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
@@ -299,16 +300,16 @@ impl RepoStore {
|
|||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||||
.authors(pubkeys.clone())
|
.authors(pubkeys.clone())
|
||||||
.identifier(addr.identifier.clone()),
|
.identifier(addr.identifier()),
|
||||||
// Activity tagging a maintainer, resolved to their read relays.
|
// Activity tagging a maintainer, resolved to their read relays.
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds(filters::ACTIVITY_KINDS)
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
.coordinate(addr)
|
.coordinate(addr.coordinate())
|
||||||
.pubkeys(pubkeys.clone()),
|
.pubkeys(pubkeys.clone()),
|
||||||
// Activity authored by a maintainer, resolved to their write relays.
|
// Activity authored by a maintainer, resolved to their write relays.
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds(filters::ACTIVITY_KINDS)
|
.kinds(filters::ACTIVITY_KINDS)
|
||||||
.coordinate(addr)
|
.coordinate(addr.coordinate())
|
||||||
.authors(pubkeys.clone()),
|
.authors(pubkeys.clone()),
|
||||||
// Deletions authored by a maintainer.
|
// Deletions authored by a maintainer.
|
||||||
Filter::new()
|
Filter::new()
|
||||||
@@ -377,10 +378,10 @@ impl RepoStore {
|
|||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let (announcements, states, activity, deletion_events) = async {
|
let (announcements, states, activity, deletion_events) = async {
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
let announcements = db.query(filters::announcement(&addr)).await?;
|
let announcements = db.query(addr.announcement_filter()).await?;
|
||||||
let states = db.query(filters::state(&addr)).await?;
|
let states = db.query(addr.state_filter()).await?;
|
||||||
let activity = db.query(filters::activity(&addr)).await?;
|
let activity = db.query(addr.activity_filter()).await?;
|
||||||
let deletion_events = db.query(filters::deletions()).await?;
|
let deletion_events = db.query(Filters::deletions()).await?;
|
||||||
|
|
||||||
Ok::<_, Error>((announcements, states, activity, deletion_events))
|
Ok::<_, Error>((announcements, states, activity, deletion_events))
|
||||||
}
|
}
|
||||||
@@ -397,7 +398,7 @@ impl RepoStore {
|
|||||||
.and_then(Announcement::from_event);
|
.and_then(Announcement::from_event);
|
||||||
|
|
||||||
let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e));
|
let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e));
|
||||||
let state = utils::latest(all_states).map(|state| parse_state(&state));
|
let state = utils::latest(all_states).map(|state| RepoState::parse(&state));
|
||||||
|
|
||||||
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
||||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||||
@@ -425,7 +426,7 @@ impl RepoStore {
|
|||||||
.chain(&pull_requests)
|
.chain(&pull_requests)
|
||||||
.map(|e| e.id);
|
.map(|e| e.id);
|
||||||
|
|
||||||
for filter in filters::comments_for(roots) {
|
for filter in Filters::comments_for(roots) {
|
||||||
for event in db.query(filter).await? {
|
for event in db.query(filter).await? {
|
||||||
if seen_comments.insert(event.id) {
|
if seen_comments.insert(event.id) {
|
||||||
comments.push(event);
|
comments.push(event);
|
||||||
@@ -443,7 +444,7 @@ impl RepoStore {
|
|||||||
.map(|e| e.id);
|
.map(|e| e.id);
|
||||||
|
|
||||||
for root in roots {
|
for root in roots {
|
||||||
for event in db.query(filters::statuses_for([root])).await? {
|
for event in db.query(Filters::statuses_for([root])).await? {
|
||||||
if seen_statuses.insert(event.id) {
|
if seen_statuses.insert(event.id) {
|
||||||
statuses.push(event);
|
statuses.push(event);
|
||||||
}
|
}
|
||||||
@@ -516,7 +517,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
let head_changed = state
|
let head_changed = state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
|
.is_some_and(|state| this.head.as_deref() != state.head.as_deref());
|
||||||
|
|
||||||
let changed = first_pass
|
let changed = first_pass
|
||||||
|| (!keep_hint && this.announcement != announcement)
|
|| (!keep_hint && this.announcement != announcement)
|
||||||
@@ -549,8 +550,8 @@ impl RepoStore {
|
|||||||
|
|
||||||
this.sync_maintainer_relays(&maintainers, cx);
|
this.sync_maintainer_relays(&maintainers, cx);
|
||||||
|
|
||||||
if let Some((_, head)) = state {
|
if let Some(state) = state {
|
||||||
this.head = head;
|
this.head = state.head;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.issues = issues;
|
this.issues = issues;
|
||||||
@@ -579,8 +580,8 @@ impl RepoStore {
|
|||||||
if !new_roots.is_empty() {
|
if !new_roots.is_empty() {
|
||||||
this.root_fetches.extend(new_roots.iter().copied());
|
this.root_fetches.extend(new_roots.iter().copied());
|
||||||
|
|
||||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
let mut root_filters = Filters::comments_for(new_roots.clone());
|
||||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
root_filters.push(Filters::statuses_for(new_roots.iter().copied()));
|
||||||
|
|
||||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
@@ -621,7 +622,7 @@ impl RepoStore {
|
|||||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
pub fn is_author(&self, user: &PublicKey) -> bool {
|
||||||
self.addr
|
self.addr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|addr| &addr.public_key == user)
|
.is_some_and(|addr| addr.public_key() == *user)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
|
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
|
||||||
@@ -631,7 +632,7 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let builder = GitIssue {
|
let builder = GitIssue {
|
||||||
repository: addr,
|
repository: addr.into(),
|
||||||
content,
|
content,
|
||||||
subject,
|
subject,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
@@ -644,7 +645,7 @@ impl RepoStore {
|
|||||||
pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
|
pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
|
||||||
self.comments
|
self.comments
|
||||||
.iter()
|
.iter()
|
||||||
.filter(move |e| signed_core::references_root(e, root))
|
.filter(move |e| e.references_root(root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comment on a root event, an issue or PR, per NIP-34, kind 1111.
|
/// Comment on a root event, an issue or PR, per NIP-34, kind 1111.
|
||||||
@@ -745,9 +746,9 @@ impl RepoStore {
|
|||||||
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
||||||
let author_npub = user.to_bech32().unwrap();
|
let author_npub = user.to_bech32().unwrap();
|
||||||
|
|
||||||
let owner = addr.public_key;
|
let owner = addr.public_key();
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let repo_id = addr.identifier.clone();
|
let repo_id = addr.identifier().to_owned();
|
||||||
let base_npub = owner.to_bech32().unwrap();
|
let base_npub = owner.to_bech32().unwrap();
|
||||||
let push_relays = self
|
let push_relays = self
|
||||||
.announcement
|
.announcement
|
||||||
@@ -857,7 +858,7 @@ impl RepoStore {
|
|||||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||||
|
|
||||||
let builder = GitPullRequest {
|
let builder = GitPullRequest {
|
||||||
repository: addr.clone(),
|
repository: addr.clone().into(),
|
||||||
content: description,
|
content: description,
|
||||||
subject,
|
subject,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
@@ -1069,7 +1070,8 @@ impl RepoStore {
|
|||||||
// The first revision patch replies to the original root patch, NIP-34.
|
// The first revision patch replies to the original root patch, NIP-34.
|
||||||
// Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
|
// Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
|
||||||
let root_patch_id = root.tags.event_ids().next().or_else(|| {
|
let root_patch_id = root.tags.event_ids().next().or_else(|| {
|
||||||
pull_request_patches(root, self.patches.iter())
|
PullRequest::new(root)
|
||||||
|
.patches(self.patches.iter())
|
||||||
.first()
|
.first()
|
||||||
.map(|p| p.id)
|
.map(|p| p.id)
|
||||||
});
|
});
|
||||||
@@ -1079,7 +1081,7 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let owner = addr.public_key;
|
let owner = addr.public_key();
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
@@ -1110,7 +1112,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
let builder = {
|
let builder = {
|
||||||
let builder = GitPullRequestUpdate {
|
let builder = GitPullRequestUpdate {
|
||||||
repository: addr.clone(),
|
repository: addr.clone().into(),
|
||||||
pull_request_event: root.id,
|
pull_request_event: root.id,
|
||||||
pull_request_author: root.pubkey,
|
pull_request_author: root.pubkey,
|
||||||
current_commit,
|
current_commit,
|
||||||
@@ -1182,9 +1184,9 @@ impl RepoStore {
|
|||||||
|
|
||||||
let builder = EventBuilder::new(status.kind(), "").tags([
|
let builder = EventBuilder::new(status.kind(), "").tags([
|
||||||
root_ref,
|
root_ref,
|
||||||
Tag::public_key(addr.public_key),
|
Tag::public_key(addr.public_key()),
|
||||||
Tag::public_key(root.pubkey),
|
Tag::public_key(root.pubkey),
|
||||||
Tag::coordinate(addr, None),
|
Tag::coordinate(addr.clone().into(), None),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self.publish(builder, cx);
|
self.publish(builder, cx);
|
||||||
@@ -1479,7 +1481,7 @@ fn resolve_statuses(
|
|||||||
.map(|root| {
|
.map(|root| {
|
||||||
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
|
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||||
let status =
|
let status =
|
||||||
signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
|
signed_core::RepoStatus::resolve(events.iter().copied(), &root.pubkey, maintainers);
|
||||||
(root.id, status)
|
(root.id, status)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1518,7 +1520,10 @@ async fn publish_patch_series(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
let mut tags = vec![
|
||||||
|
Tag::coordinate(addr.clone().into(), None),
|
||||||
|
Tag::public_key(owner),
|
||||||
|
];
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
||||||
@@ -1586,11 +1591,13 @@ fn comment_builder(
|
|||||||
CommentBuilder::new(content, parent_target)
|
CommentBuilder::new(content, parent_target)
|
||||||
.root(root_target)
|
.root(root_target)
|
||||||
.into_event_builder()
|
.into_event_builder()
|
||||||
.tags([Tag::coordinate(addr.clone(), None)])
|
.tags([Tag::coordinate(addr.clone().into(), None)])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use signed_core::GitEvent;
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -1612,7 +1619,7 @@ mod tests {
|
|||||||
let root = EventBuilder::new(Kind::GitIssue, "issue body")
|
let root = EventBuilder::new(Kind::GitIssue, "issue body")
|
||||||
.finalize(&keys)
|
.finalize(&keys)
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo");
|
let addr = signed_core::RepoAddr::new(root.pubkey, "my-repo");
|
||||||
let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL");
|
let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL");
|
||||||
|
|
||||||
let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into())
|
let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into())
|
||||||
@@ -1639,14 +1646,14 @@ mod tests {
|
|||||||
assert_eq!(e.as_slice()[1], root.id.to_hex());
|
assert_eq!(e.as_slice()[1], root.id.to_hex());
|
||||||
|
|
||||||
// Signed's own `references_root` must keep matching the comment.
|
// Signed's own `references_root` must keep matching the comment.
|
||||||
assert!(signed_core::references_root(&event, &root.id));
|
assert!(event.references_root(&root.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maintainer_filters_name_owner_and_maintainers() {
|
fn maintainer_filters_name_owner_and_maintainers() {
|
||||||
let owner = Keys::generate().public_key();
|
let owner = Keys::generate().public_key();
|
||||||
let maintainer = Keys::generate().public_key();
|
let maintainer = Keys::generate().public_key();
|
||||||
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
|
let addr = signed_core::RepoAddr::new(owner, "my-repo");
|
||||||
|
|
||||||
// The owner is not among the maintainers, as on a subordinate fork.
|
// The owner is not among the maintainers, as on a subordinate fork.
|
||||||
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
@@ -115,10 +115,10 @@ impl RepoListStore {
|
|||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.sync_bootstraps(
|
backend.sync_bootstraps(
|
||||||
vec![
|
vec![
|
||||||
filters::all_announcements(),
|
Filters::all_announcements(),
|
||||||
filters::all_states(),
|
Filters::all_states(),
|
||||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||||
filters::deletions(),
|
Filters::deletions(),
|
||||||
],
|
],
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -144,7 +144,7 @@ impl RepoListStore {
|
|||||||
self.state_synced_repos.insert(addr.clone());
|
self.state_synced_repos.insert(addr.clone());
|
||||||
|
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx);
|
backend.connect_repo_relays(relays, vec![addr.state_filter()], cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,10 +168,10 @@ impl RepoListStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = filters::all_announcements();
|
let filter = Filters::all_announcements();
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
let deletion_events = client.database().query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
// Dedup and sort off the main thread.
|
// Dedup and sort off the main thread.
|
||||||
@@ -213,7 +213,7 @@ impl RepoListStore {
|
|||||||
let Some(id) = event.tags.identifier() else {
|
let Some(id) = event.tags.identifier() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let addr = repo_addr(event.pubkey, id);
|
let addr = RepoAddr::new(event.pubkey, id);
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -230,10 +230,11 @@ impl RepoListStore {
|
|||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for coordinate in event.tags.coordinates() {
|
||||||
if addr.kind != Kind::GitRepoAnnouncement {
|
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let addr = RepoAddr::from(coordinate.clone());
|
||||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -251,12 +252,13 @@ impl RepoListStore {
|
|||||||
if deletions.is_deleted(&event) {
|
if deletions.is_deleted(&event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for addr in event.tags.coordinates() {
|
for coordinate in event.tags.coordinates() {
|
||||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
if coordinate.kind != Kind::GitRepoAnnouncement
|
||||||
|
|| !last_activity.contains_key(&RepoAddr::from(coordinate.clone()))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry = counts.entry(addr).or_default();
|
let entry = counts.entry(RepoAddr::from(coordinate)).or_default();
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue => entry.issues += 1,
|
Kind::GitIssue => entry.issues += 1,
|
||||||
Kind::GitPullRequest => entry.pull_requests += 1,
|
Kind::GitPullRequest => entry.pull_requests += 1,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use gpui_component::input::TextareaState;
|
|||||||
use gpui_component::scroll::ScrollableElement;
|
use gpui_component::scroll::ScrollableElement;
|
||||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::activity_subject;
|
use signed_core::GitEvent;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, placeholder, status_badge};
|
use signed_ui::{Avatar, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -103,7 +103,7 @@ impl Render for IssueDetailView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
(
|
(
|
||||||
activity_subject(issue),
|
issue.activity_subject(),
|
||||||
profile.name(),
|
profile.name(),
|
||||||
profile.picture(),
|
profile.picture(),
|
||||||
store.status_of(issue),
|
store.status_of(issue),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::EventId;
|
use nostr::prelude::EventId;
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -146,7 +146,7 @@ impl IssuesView {
|
|||||||
|
|
||||||
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let issue = &self.store.read(cx).issues[issue_ix];
|
let issue = &self.store.read(cx).issues[issue_ix];
|
||||||
let title = activity_subject(issue);
|
let title = issue.activity_subject();
|
||||||
let id_hex = issue.id.to_hex();
|
let id_hex = issue.id.to_hex();
|
||||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
||||||
let author = profile.name();
|
let author = profile.name();
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ use gpui_component::{
|
|||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{Event, EventId, Kind, Url};
|
use nostr::prelude::{Event, EventId, Kind, Url};
|
||||||
use signed_core::{
|
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||||
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
|
|
||||||
merge_base_of, pull_request_patch,
|
|
||||||
};
|
|
||||||
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
use signed_git::{FileCommit, patch_commits, patch_diffs};
|
||||||
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror};
|
||||||
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
use signed_ui::{Avatar, CountBadge, placeholder, status_badge};
|
||||||
@@ -145,23 +142,24 @@ impl PullRequestDetailView {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||||
.map(|root| {
|
.map(|root| {
|
||||||
let update = latest_update(store.pull_requests.iter(), root);
|
let update = PullRequest::latest_update(store.pull_requests.iter(), root);
|
||||||
|
|
||||||
let tip = update
|
let tip = update
|
||||||
.and_then(current_commit_of)
|
.and_then(GitEvent::current_commit)
|
||||||
.or_else(|| current_commit_of(root));
|
.or_else(|| root.current_commit());
|
||||||
|
|
||||||
let base = update
|
let base = update
|
||||||
.and_then(merge_base_of)
|
.and_then(GitEvent::merge_base)
|
||||||
.or_else(|| merge_base_of(root));
|
.or_else(|| root.merge_base());
|
||||||
|
|
||||||
let clone_urls = clone_urls_of(root)
|
let clone_urls = root
|
||||||
|
.clone_urls()
|
||||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
PrBinding {
|
PrBinding {
|
||||||
description: root.content.clone(),
|
description: root.content.clone(),
|
||||||
patch: pull_request_patch(root, store.patches.iter()),
|
patch: PullRequest::new(root).patch(store.patches.iter()),
|
||||||
tip,
|
tip,
|
||||||
base,
|
base,
|
||||||
clone_urls,
|
clone_urls,
|
||||||
@@ -620,9 +618,9 @@ impl PullRequestDetailView {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
activity_subject(root),
|
root.activity_subject(),
|
||||||
store.status_of(root),
|
store.status_of(root),
|
||||||
branch_name_of(root),
|
root.branch_name(),
|
||||||
root.pubkey,
|
root.pubkey,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use gpui_component::{
|
|||||||
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{EventId, Kind};
|
use nostr::prelude::{EventId, Kind};
|
||||||
use signed_core::{RepoStatus, activity_subject};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileStore, RepoStore};
|
||||||
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -159,7 +159,7 @@ impl PullRequestsView {
|
|||||||
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
let pr = &self.store.read(cx).pull_requests[pr_ix];
|
||||||
let pr_id = pr.id;
|
let pr_id = pr.id;
|
||||||
let title = activity_subject(pr);
|
let title = pr.activity_subject();
|
||||||
let id_hex = pr.id.to_hex();
|
let id_hex = pr.id.to_hex();
|
||||||
|
|
||||||
let age = relative_time(pr.created_at);
|
let age = relative_time(pr.created_at);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use gpui_component::{
|
|||||||
v_virtual_list,
|
v_virtual_list,
|
||||||
};
|
};
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use signed_core::{Announcement, RepoAddr, fork_candidates};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_git::{
|
use signed_git::{
|
||||||
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
|
||||||
worktree_commit_range_commits, worktree_commit_range_diff,
|
worktree_commit_range_commits, worktree_commit_range_diff,
|
||||||
@@ -512,7 +512,7 @@ impl NewPullRequestView {
|
|||||||
};
|
};
|
||||||
let user = Backend::global(cx).read(cx).current_user();
|
let user = Backend::global(cx).read(cx).current_user();
|
||||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||||
fork_candidates(&announcements, &base, euc.as_deref(), user)
|
Announcement::forks_in(&announcements, &base, euc.as_deref(), user)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ impl RepoDetailView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|name| name.to_string())
|
.map(|name| name.to_string())
|
||||||
.filter(|name| !name.trim().is_empty())
|
.filter(|name| !name.trim().is_empty())
|
||||||
.unwrap_or_else(|| addr.identifier.clone());
|
.unwrap_or_else(|| addr.identifier().to_owned());
|
||||||
let name = signed_git::sanitize_path_component(&name);
|
let name = signed_git::sanitize_path_component(&name);
|
||||||
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -1857,7 +1857,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Op
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||||
(SharedString::from(format!("Forked from {name}")), true)
|
(SharedString::from(format!("Forked from {name}")), true)
|
||||||
}
|
}
|
||||||
None => (SharedString::from(upstream.display().as_str()), false),
|
None => (SharedString::from(upstream.display().as_str()), false),
|
||||||
@@ -1900,7 +1900,7 @@ impl ShareTargets {
|
|||||||
fn from_announcement(announcement: &Announcement) -> Self {
|
fn from_announcement(announcement: &Announcement) -> Self {
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
let coordinate = addr.to_string();
|
let coordinate = addr.to_string();
|
||||||
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
|
let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned())
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.expect("a complete coordinate always encodes to naddr");
|
.expect("a complete coordinate always encodes to naddr");
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ impl RepoListView {
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||||
Some(SharedString::from(format!("forked from {name}")))
|
Some(SharedString::from(format!("forked from {name}")))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user