mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
fix(deletion): remove and recover PR git refs
This commit is contained in:
+91
-14
@@ -79,6 +79,48 @@ pub fn event_names_relays_prs_endpoint(event: &Event, domain: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Return all distinct `<d>` identifiers named by this event's `clone` tags
|
||||
/// that target this relay's `/prs/<signer-npub>/<d>.git` endpoint and also
|
||||
/// appear in the event's `a` tags.
|
||||
///
|
||||
/// This is used by deletion/recovery code paths to map a PR/PR-update event to
|
||||
/// its scoped `/prs/` bare repository locations.
|
||||
pub fn prs_identifiers_named_by_event_clone_tags(event: &Event, domain: &str) -> Vec<String> {
|
||||
if !matches!(
|
||||
event.kind,
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||||
) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let d_tags: std::collections::HashSet<String> =
|
||||
collect_a_tag_d_values(event).into_iter().collect();
|
||||
if d_tags.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.clone().to_vec();
|
||||
if parts.first().map(String::as_str) != Some("clone") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for url in parts.iter().skip(1) {
|
||||
let Some(identifier) = clone_url_prs_identifier_for_relay(url, domain, &event.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if d_tags.contains(&identifier) {
|
||||
out.insert(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Extract `<d>` from every well-formed `a` tag of the form
|
||||
/// `30617:<64-hex-pubkey>:<d>` on `event`. Malformed tags are silently
|
||||
/// skipped — a malformed `a` tag must not crash, it just means the event
|
||||
@@ -129,68 +171,80 @@ pub fn clone_url_names_relays_prs_endpoint(
|
||||
signer: &PublicKey,
|
||||
d_tags: &[String],
|
||||
) -> bool {
|
||||
let Some(d_decoded) = clone_url_prs_identifier_for_relay(url, domain, signer) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
d_tags.contains(&d_decoded)
|
||||
}
|
||||
|
||||
/// Parse `url` as this relay's `/prs/<signer-npub>/<d>.git` endpoint and
|
||||
/// return the decoded `<d>` when it matches.
|
||||
fn clone_url_prs_identifier_for_relay(
|
||||
url: &str,
|
||||
domain: &str,
|
||||
signer: &PublicKey,
|
||||
) -> Option<String> {
|
||||
// Scheme: http or https only, case-insensitive.
|
||||
let rest = if let Some(r) = strip_prefix_ignore_ascii_case(url, "http://") {
|
||||
r
|
||||
} else if let Some(r) = strip_prefix_ignore_ascii_case(url, "https://") {
|
||||
r
|
||||
} else {
|
||||
return false;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Reject query strings and fragments outright.
|
||||
if rest.contains('?') || rest.contains('#') {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split authority and path on the first `/`.
|
||||
let slash_idx = match rest.find('/') {
|
||||
Some(i) => i,
|
||||
None => return false,
|
||||
None => return None,
|
||||
};
|
||||
let authority = &rest[..slash_idx];
|
||||
let path = &rest[slash_idx..]; // includes leading `/`.
|
||||
|
||||
if !authority.eq_ignore_ascii_case(domain) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = path.trim_end_matches('/');
|
||||
|
||||
let inner = match path.strip_prefix(&format!("/{}/", PRS_URL_PREFIX)) {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Exactly two segments: `<npub>` and `<repo>.git`.
|
||||
let segments: Vec<&str> = inner.split('/').collect();
|
||||
if segments.len() != 2 {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let npub_segment = segments[0];
|
||||
let repo_segment = segments[1];
|
||||
|
||||
if !npub_segment.starts_with("npub1") {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let url_pubkey = match PublicKey::from_bech32(npub_segment) {
|
||||
Ok(pk) => pk,
|
||||
Err(_) => return false,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if url_pubkey != *signer {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
|
||||
let d_encoded = match repo_segment.strip_suffix(".git") {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
None => return None,
|
||||
};
|
||||
if d_encoded.is_empty() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let d_decoded = percent_decode(d_encoded);
|
||||
|
||||
d_tags.contains(&d_decoded)
|
||||
Some(percent_decode(d_encoded))
|
||||
}
|
||||
|
||||
/// Case-insensitive equivalent of `str::strip_prefix` for ASCII prefixes.
|
||||
@@ -290,6 +344,29 @@ mod tests {
|
||||
assert!(event_qualifies_for_pr_relaxation(&event, &cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_prs_identifiers_from_clone_tags() {
|
||||
let signer = Keys::generate();
|
||||
let target_hex = Keys::generate().public_key().to_hex();
|
||||
let npub = signer.public_key().to_bech32().unwrap();
|
||||
let event = pr_event(
|
||||
&signer,
|
||||
&[(&target_hex, "my-repo"), (&target_hex, "other-repo")],
|
||||
&[
|
||||
&format!("http://relay.example/prs/{}/my-repo.git", npub),
|
||||
&format!("https://relay.example/prs/{}/other-repo.git", npub),
|
||||
&format!("https://other.example/prs/{}/my-repo.git", npub),
|
||||
],
|
||||
Kind::GitPullRequest,
|
||||
);
|
||||
|
||||
let identifiers = prs_identifiers_named_by_event_clone_tags(&event, "relay.example");
|
||||
assert_eq!(
|
||||
identifiers,
|
||||
vec!["my-repo".to_string(), "other-repo".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_kinds() {
|
||||
let signer = Keys::generate();
|
||||
|
||||
@@ -1759,6 +1759,123 @@ impl Nip34WritePolicy {
|
||||
Ok(restored_any)
|
||||
}
|
||||
|
||||
fn pr_event_commit(event: &Event) -> Option<String> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
if parts.len() >= 2 && parts[0] == "c" {
|
||||
Some(parts[1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn pr_event_identifiers(event: &Event) -> BTreeSet<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
if parts.len() < 2 || parts[0] != "a" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let coord: Vec<&str> = parts[1].splitn(3, ':').collect();
|
||||
if coord.len() != 3 || coord[0] != "30617" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(coord[2].to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn pr_target_repo_paths_for_recovery(&self, event: &Event) -> BTreeSet<PathBuf> {
|
||||
let mut paths = BTreeSet::new();
|
||||
|
||||
for identifier in Self::pr_event_identifiers(event) {
|
||||
let Ok(repo_data) =
|
||||
crate::git::authorization::fetch_repository_data_excluding_purgatory(
|
||||
&self.ctx.database,
|
||||
&identifier,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for announcement in &repo_data.announcements {
|
||||
paths.insert(self.ctx.git_data_path.join(announcement.repo_path()));
|
||||
}
|
||||
}
|
||||
|
||||
for identifier in crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(
|
||||
event,
|
||||
&self.ctx.config.domain,
|
||||
) {
|
||||
paths.insert(crate::grasp06::paths::prs_repo_path(
|
||||
&self.ctx.git_data_path,
|
||||
&event.pubkey.to_hex(),
|
||||
&identifier,
|
||||
));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
async fn restore_pr_git_refs_for_recovered_event(&self, event: &Event) -> anyhow::Result<bool> {
|
||||
if !matches!(
|
||||
event.kind,
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some(commit) = Self::pr_event_commit(event) else {
|
||||
tracing::warn!(
|
||||
event_id = %event.id.to_hex(),
|
||||
"Recovery could not restore PR git ref: event missing c-tag"
|
||||
);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let ref_name = format!("refs/nostr/{}", event.id.to_hex());
|
||||
let mut restored_any = false;
|
||||
let repo_paths = self.pr_target_repo_paths_for_recovery(event).await;
|
||||
|
||||
for repo_path in repo_paths {
|
||||
if !repo_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !crate::git::commit_exists(&repo_path, &commit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::git::update_ref(&repo_path, &ref_name, &commit).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to restore PR ref {} -> {} in {}: {}",
|
||||
ref_name,
|
||||
commit,
|
||||
repo_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
restored_any = true;
|
||||
}
|
||||
|
||||
if !restored_any {
|
||||
tracing::warn!(
|
||||
event_id = %event.id.to_hex(),
|
||||
ref_name = %ref_name,
|
||||
commit = %commit,
|
||||
"Recovery did not find a repository with commit needed to restore PR ref"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(restored_any)
|
||||
}
|
||||
|
||||
async fn restore_events_from_holding(
|
||||
&self,
|
||||
records: &[RecoveryMetadataRecord],
|
||||
@@ -1814,6 +1931,28 @@ impl Nip34WritePolicy {
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.kind,
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||||
) {
|
||||
match self.restore_pr_git_refs_for_recovered_event(&event).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
blocked.push(record.clone());
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
event_id = %event_id.to_hex(),
|
||||
"Recovery failed restoring PR git refs"
|
||||
);
|
||||
blocked.push(record.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restorable.push(record.clone());
|
||||
}
|
||||
Ok(None) => {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
///
|
||||
/// Author ownership is enforced before any main-DB deletion or tombstone
|
||||
/// recording: only the original author may delete their event.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1227,6 +1227,25 @@ impl DeletionPolicy {
|
||||
for event in matches {
|
||||
let mut event_metadata = metadata.clone();
|
||||
|
||||
if matches!(
|
||||
event.kind,
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||||
) {
|
||||
if let Some((coord, owner_hex, identifier)) =
|
||||
Self::pr_event_primary_announcement_scope(&event)
|
||||
{
|
||||
if event_metadata.coordinate.is_none() {
|
||||
event_metadata.coordinate = Some(coord);
|
||||
}
|
||||
if event_metadata.identifier.is_none() {
|
||||
event_metadata.identifier = Some(identifier);
|
||||
}
|
||||
if event_metadata.owner_pubkey.is_none() {
|
||||
event_metadata.owner_pubkey = Some(owner_hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.kind == Kind::GitRepoAnnouncement {
|
||||
let owner_hex = event.pubkey.to_hex();
|
||||
let owner_dir = owner_directory_component(&event.pubkey);
|
||||
@@ -1272,6 +1291,13 @@ impl DeletionPolicy {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.kind,
|
||||
Kind::GitPullRequest | Kind::GitPullRequestUpdate
|
||||
) {
|
||||
self.delete_pr_event_git_refs(&event).await;
|
||||
}
|
||||
}
|
||||
deletable.push(event.id);
|
||||
}
|
||||
@@ -1286,6 +1312,150 @@ impl DeletionPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn pr_event_primary_announcement_scope(event: &Event) -> Option<(String, String, String)> {
|
||||
for coordinate in Self::a_tag_coordinates(event) {
|
||||
let Some((kind, owner, identifier)) = Self::parse_coordinate(&coordinate) else {
|
||||
continue;
|
||||
};
|
||||
if kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Some((
|
||||
format!("30617:{}:{}", owner.to_hex(), identifier),
|
||||
owner.to_hex(),
|
||||
identifier,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn pr_identifiers_from_a_tags(event: &Event) -> BTreeSet<String> {
|
||||
let mut identifiers = BTreeSet::new();
|
||||
for coordinate in Self::a_tag_coordinates(event) {
|
||||
let Some((kind, _, identifier)) = Self::parse_coordinate(&coordinate) else {
|
||||
continue;
|
||||
};
|
||||
if kind == Kind::GitRepoAnnouncement {
|
||||
identifiers.insert(identifier);
|
||||
}
|
||||
}
|
||||
identifiers
|
||||
}
|
||||
|
||||
async fn collect_normal_repo_paths_for_pr_event(&self, event: &Event) -> BTreeSet<PathBuf> {
|
||||
let mut paths = BTreeSet::new();
|
||||
|
||||
for coordinate in Self::a_tag_coordinates(event) {
|
||||
let Some((kind, owner, identifier)) = Self::parse_coordinate(&coordinate) else {
|
||||
continue;
|
||||
};
|
||||
if kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.insert(self.repo_path_for_owner_and_identifier(
|
||||
&owner_directory_component(&owner),
|
||||
&identifier,
|
||||
));
|
||||
}
|
||||
|
||||
for identifier in Self::pr_identifiers_from_a_tags(event) {
|
||||
let Ok(repo_data) =
|
||||
fetch_repository_data_excluding_purgatory(&self.ctx.database, &identifier).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for announcement in &repo_data.announcements {
|
||||
paths.insert(self.ctx.git_data_path.join(announcement.repo_path()));
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn collect_grasp06_repo_paths_for_pr_event(&self, event: &Event) -> BTreeSet<PathBuf> {
|
||||
crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(
|
||||
event,
|
||||
&self.ctx.config.domain,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|identifier| {
|
||||
crate::grasp06::paths::prs_repo_path(
|
||||
&self.ctx.git_data_path,
|
||||
&event.pubkey.to_hex(),
|
||||
&identifier,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cleanup_zero_ref_grasp06_repo_if_idle(&self, repo_path: &PathBuf) {
|
||||
if !crate::grasp06::paths::is_prs_repo_path(repo_path, &self.ctx.git_data_path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = crate::grasp06::receive::path_state(&self.ctx.repo_init_locks, repo_path);
|
||||
let _guard = state.mu.lock().expect("prs path mutex poisoned");
|
||||
|
||||
if state.in_flight.load(std::sync::atomic::Ordering::Relaxed) != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_zero_ref = matches!(git::list_refs(repo_path), Ok(refs) if refs.is_empty());
|
||||
if !is_zero_ref {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::remove_dir_all(repo_path) {
|
||||
tracing::warn!(
|
||||
repo = %repo_path.display(),
|
||||
error = %e,
|
||||
"Failed to remove zero-ref /prs/ repo while processing deletion request"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(parent) = repo_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_pr_event_git_refs(&self, event: &Event) {
|
||||
let ref_name = format!("refs/nostr/{}", event.id.to_hex());
|
||||
let mut repo_paths = self.collect_normal_repo_paths_for_pr_event(event).await;
|
||||
repo_paths.extend(self.collect_grasp06_repo_paths_for_pr_event(event));
|
||||
|
||||
for repo_path in repo_paths {
|
||||
if !repo_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match git::delete_ref(&repo_path, &ref_name) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
event_id = %event.id.to_hex(),
|
||||
repo = %repo_path.display(),
|
||||
ref_name = %ref_name,
|
||||
"Deleted PR git ref for deletion-requested event"
|
||||
);
|
||||
self.cleanup_zero_ref_grasp06_repo_if_idle(&repo_path);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
event_id = %event.id.to_hex(),
|
||||
repo = %repo_path.display(),
|
||||
ref_name = %ref_name,
|
||||
error = %e,
|
||||
"PR git ref not deleted for this repository"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_output_path(
|
||||
&self,
|
||||
owner_path_component: &str,
|
||||
@@ -1980,4 +2150,60 @@ mod tests {
|
||||
"Disrespector mode must NOT delete the target from the main DB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grasp06_pr_clone_tags_map_to_prs_repo_paths() {
|
||||
let db = Arc::new(nostr_memory::MemoryDatabase::unbounded());
|
||||
let purgatory = Arc::new(Purgatory::new(PathBuf::new()));
|
||||
let git_data_path = PathBuf::from("/tmp/ngit-grasp-test-git");
|
||||
let config = crate::config::Config {
|
||||
domain: "test.example.com".to_string(),
|
||||
..crate::config::Config::for_testing()
|
||||
};
|
||||
let ctx = PolicyContext::new_for_test(
|
||||
"test.example.com",
|
||||
db,
|
||||
git_data_path.clone(),
|
||||
purgatory,
|
||||
config,
|
||||
);
|
||||
|
||||
let signer = Keys::generate();
|
||||
let target = Keys::generate();
|
||||
let npub = signer.public_key().to_bech32().expect("pubkey to bech32");
|
||||
let event = EventBuilder::new(Kind::GitPullRequest, "prs clone test")
|
||||
.tags(vec![
|
||||
Tag::custom(
|
||||
"a",
|
||||
vec![format!(
|
||||
"30617:{}:{}",
|
||||
target.public_key().to_hex(),
|
||||
"my-repo"
|
||||
)],
|
||||
),
|
||||
Tag::custom(
|
||||
"clone",
|
||||
vec![format!(
|
||||
"http://test.example.com/prs/{}/{}.git",
|
||||
npub, "my-repo"
|
||||
)],
|
||||
),
|
||||
Tag::custom("c", vec!["0".repeat(40)]),
|
||||
])
|
||||
.finalize(&signer)
|
||||
.expect("build pr event");
|
||||
|
||||
let policy = DeletionPolicy::new(ctx.clone());
|
||||
let paths = policy.collect_grasp06_repo_paths_for_pr_event(&event);
|
||||
let expected = crate::grasp06::paths::prs_repo_path(
|
||||
&git_data_path,
|
||||
&signer.public_key().to_hex(),
|
||||
"my-repo",
|
||||
);
|
||||
|
||||
assert!(
|
||||
paths.contains(&expected),
|
||||
"expected /prs/ repo path derived from clone tag"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,24 @@ use common::{
|
||||
use grasp_audit::{clone_repo, AuditClient, AuditConfig, DETERMINISTIC_COMMIT_HASH};
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
fn repo_has_ref(repo_path: &Path, ref_name: &str) -> bool {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"--git-dir",
|
||||
repo_path.to_str().expect("repo path utf8"),
|
||||
"show-ref",
|
||||
"--verify",
|
||||
ref_name,
|
||||
])
|
||||
.output()
|
||||
.expect("run git show-ref");
|
||||
output.status.success()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_announcement_cascade_deletes_supported_dependent_kinds() {
|
||||
let relay = TestRelay::start().await;
|
||||
@@ -176,6 +192,19 @@ async fn test_announcement_cascade_deletes_served_pr_chain_kinds() {
|
||||
.expect("push PR update ref should succeed");
|
||||
fs::remove_dir_all(&clone_path).expect("remove cloned repo");
|
||||
|
||||
let repo_path = relay
|
||||
.git_data_path()
|
||||
.join(client.public_key().to_bech32().expect("pubkey to bech32"))
|
||||
.join(format!("{}.git", repo_id));
|
||||
assert!(
|
||||
repo_has_ref(&repo_path, &pr_ref),
|
||||
"PR ref should exist before deletion"
|
||||
);
|
||||
assert!(
|
||||
repo_has_ref(&repo_path, &pr_update_ref),
|
||||
"PR update ref should exist before deletion"
|
||||
);
|
||||
|
||||
let pr_status = client
|
||||
.event_builder(Kind::from(1631), "pr status")
|
||||
.tag(Tag::custom("e", vec![pr.id.to_hex()]))
|
||||
@@ -239,6 +268,14 @@ async fn test_announcement_cascade_deletes_served_pr_chain_kinds() {
|
||||
survivors.is_empty(),
|
||||
"announcement deletion should cascade across PR-chain dependent kinds; survivors: {survivors:?}"
|
||||
);
|
||||
assert!(
|
||||
!repo_has_ref(&repo_path, &pr_ref),
|
||||
"announcement deletion should remove PR git ref"
|
||||
);
|
||||
assert!(
|
||||
!repo_has_ref(&repo_path, &pr_update_ref),
|
||||
"announcement deletion should remove PR update git ref"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user