reorganize signed_core into method-based APIs

This commit is contained in:
2026-10-03 12:03:29 +07:00
parent d00ee33eab
commit 023267001d
20 changed files with 833 additions and 697 deletions
+282 -209
View File
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use nostr::prelude::*;
use crate::{RepoAddr, repo_addr};
use crate::RepoAddr;
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -49,7 +49,8 @@ impl Upstream {
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
@@ -64,216 +65,288 @@ impl Upstream {
}
}
/// Subject of a NIP-34 issue or pull request event.
/// Taken from the `subject` tag, else the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> String {
let subject = event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => None,
});
/// Tag accessors for NIP-34 git collaboration events.
pub trait GitEvent {
/// Subject of an issue or pull request event.
fn activity_subject(&self) -> String;
subject
.or_else(|| {
event
.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|value| value.to_string())
})
.unwrap_or("Untitled".to_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;
}
/// The patch set of a pull request.
///
/// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>(
pr: &Event,
patches: impl IntoIterator<Item = &'a Event>,
) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect();
impl GitEvent for Event {
fn activity_subject(&self) -> String {
let subject = self
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => None,
});
// 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()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return forward_series(root, &patches);
subject
.or_else(|| {
self.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|value| value.to_string())
})
.unwrap_or("Untitled".to_string())
}
// The PR has no `e` 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) = current_commit_of(pr) else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
fn current_commit(&self) -> Option<String> {
self.tags
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.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()))
}
}
impl<T: GitEvent + ?Sized> GitEvent for &T {
fn activity_subject(&self) -> String {
(*self).activity_subject()
}
fn current_commit(&self) -> Option<String> {
(*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();
// The PR references its root patch via an `e` tag.
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return Self::forward_series(root, &patches);
}
// The PR has no `e` tag.
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
let Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
break;
return Vec::new();
};
series.push(prev);
}
series.reverse();
series
}
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// The `c` tag of an event, the tip of the proposed branch, as hex.
pub fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
pub fn merge_base_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.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
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.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],
base: &RepoAddr,
base_euc: Option<&str>,
user: Option<PublicKey>,
) -> Vec<&'a Announcement> {
let (mut own, mut others) = (Vec::new(), Vec::new());
for announcement in announcements {
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
continue;
}
if Some(announcement.owner) == user {
own.push(announcement);
} else {
others.push(announcement);
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
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,
})
/// 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 series = self.patches(patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// 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(
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];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// 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 {
/// The announced forks of `base` a new pull request compare can be built from.
///
/// The user's own forks are listed first.
pub fn forks_in<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
base_euc: Option<&str>,
user: Option<PublicKey>,
) -> Vec<&'a Announcement> {
let (mut own, mut others) = (Vec::new(), Vec::new());
for announcement in announcements {
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
continue;
}
if Some(announcement.owner) == user {
own.push(announcement);
} else {
others.push(announcement);
}
}
own.into_iter().chain(others).collect()
}
/// Parse a kind `30617` event.
///
/// Returns `None` when the kind is wrong or the `d` tag is missing.
@@ -337,7 +410,7 @@ impl Announcement {
}
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.
@@ -499,7 +572,7 @@ mod tests {
// The raw value keeps the `|git-url` suffix.
assert_eq!(
upstream.addr,
Some(crate::repo_addr(
Some(RepoAddr::new(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
@@ -517,7 +590,7 @@ mod tests {
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
// 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"),
"upstream",
);
@@ -559,7 +632,7 @@ mod tests {
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
// A permanent fork re-announces its EUC, the first commit after the fork.
// 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"),
"upstream",
);
@@ -632,11 +705,11 @@ mod tests {
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
pull_request_patch(&pr, [&root, &second]),
PullRequest::new(&pr).patch([&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
pull_request_patches(&pr, [&root, &second]),
PullRequest::new(&pr).patches([&root, &second]),
vec![&root, &second]
);
}
@@ -648,7 +721,7 @@ mod tests {
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
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!(
series
.iter()
@@ -677,7 +750,7 @@ mod tests {
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!(
series
.iter()
@@ -729,7 +802,7 @@ mod tests {
);
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.kind, Kind::GitPullRequestUpdate);
@@ -751,7 +824,7 @@ mod tests {
// The tip of a PR is only mutable by its author.
// 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] = [
@@ -784,7 +857,7 @@ mod tests {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
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"),
"upstream",
);
@@ -819,7 +892,7 @@ mod tests {
];
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();
assert_eq!(ids, vec!["my-fork", "their-fork"]);
@@ -829,7 +902,7 @@ mod tests {
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
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![
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
@@ -859,7 +932,7 @@ mod tests {
.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[0].id, "mirror");
@@ -876,7 +949,7 @@ mod tests {
.pop()
.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();
assert_eq!(ids, vec!["u-fork"]);
}