use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const REGISTRY_FILENAME: &str = "pr-cleanup.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenedPr {
pub pr: u64,
pub repo: String,
pub repo_root: PathBuf,
pub opened_at: DateTime<Utc>,
#[serde(default)]
pub cleaned_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "CleanupScope::is_wide")]
pub scope: CleanupScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CleanupScope {
#[default]
Wide,
HeadOnly,
Deferred,
}
impl CleanupScope {
pub fn is_wide(&self) -> bool {
*self == Self::Wide
}
}
impl OpenedPr {
pub fn pending(&self) -> bool {
self.cleaned_at.is_none() && self.scope != CleanupScope::Deferred
}
}
#[derive(Debug, Clone)]
pub struct CleanupRegistry {
path: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct RegistryFile {
#[serde(default)]
format: u32,
#[serde(default)]
entries: Vec<OpenedPr>,
}
const FORMAT_SCOPED: u32 = 2;
static DOWNGRADE_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn same_pr(e: &OpenedPr, repo: &str, pr: u64) -> bool {
e.pr == pr && e.repo.eq_ignore_ascii_case(repo)
}
impl CleanupRegistry {
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn under_root(root: impl AsRef<Path>) -> Self {
Self::at(root.as_ref().join(REGISTRY_FILENAME))
}
pub fn production() -> Self {
Self::under_root(crate::core::paths::FrameworkPaths::default().root)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn entries(&self) -> Vec<OpenedPr> {
let Ok(raw) = std::fs::read_to_string(&self.path) else {
return Vec::new();
};
let Ok(file) = serde_json::from_str::<RegistryFile>(&raw) else {
return Vec::new();
};
if self.downgraded(&file)
&& !DOWNGRADE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed)
{
tracing::warn!(
path = %self.path.display(),
"the PR cleanup registry was rewritten by an older tm or daemon, which drops \
recorded cleanup scopes (--no-cleanup, head-only); restart the daemon on the \
upgraded binary (#8301)"
);
}
file.entries
}
pub fn entry(&self, repo: &str, pr: u64) -> Option<OpenedPr> {
self.entries().into_iter().find(|e| same_pr(e, repo, pr))
}
fn scoped_marker(&self) -> PathBuf {
let mut name = self.path.file_name().unwrap_or_default().to_os_string();
name.push(".scoped");
self.path.with_file_name(name)
}
fn downgraded(&self, file: &RegistryFile) -> bool {
file.format < FORMAT_SCOPED && self.scoped_marker().exists()
}
pub fn rewritten_by_older_writer(&self) -> bool {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|raw| serde_json::from_str::<RegistryFile>(&raw).ok())
.is_some_and(|f| self.downgraded(&f))
}
pub fn pending(&self) -> Vec<OpenedPr> {
self.entries()
.into_iter()
.filter(OpenedPr::pending)
.collect()
}
pub fn record_open(&self, entry: OpenedPr) -> anyhow::Result<()> {
self.update(|entries| {
entries.retain(|e| !same_pr(e, &entry.repo, entry.pr));
entries.push(entry);
true
})
.map(|_| ())
}
pub fn mark_cleaned(&self, repo: &str, pr: u64, at: DateTime<Utc>) -> anyhow::Result<()> {
self.update(|entries| {
let mut hit = false;
for e in entries.iter_mut().filter(|e| same_pr(e, repo, pr)) {
e.cleaned_at = Some(at);
hit = true;
}
hit
})
.map(|_| ())
}
pub fn record_scope(&self, repo: &str, pr: u64, scope: CleanupScope) -> anyhow::Result<bool> {
self.update(|entries| {
let mut hit = false;
for e in entries.iter_mut().filter(|e| same_pr(e, repo, pr)) {
e.scope = scope;
hit = true;
}
hit
})
}
fn update(&self, f: impl FnOnce(&mut Vec<OpenedPr>) -> bool) -> anyhow::Result<bool> {
trusty_common::file_lock::with_exclusive_lock(&self.path, || {
let mut entries = match std::fs::read_to_string(&self.path) {
Ok(raw) => {
serde_json::from_str::<RegistryFile>(&raw)
.map_err(|e| anyhow::anyhow!("cannot parse {}: {e}", self.path.display()))?
.entries
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => anyhow::bail!("cannot read {}: {e}", self.path.display()),
};
let changed = f(&mut entries);
if changed {
self.write(entries)?;
}
Ok(changed)
})
.map_err(|e| anyhow::anyhow!("cannot lock {}: {e}", self.path.display()))?
}
fn write(&self, entries: Vec<OpenedPr>) -> anyhow::Result<()> {
let body = serde_json::to_string_pretty(&RegistryFile {
format: FORMAT_SCOPED,
entries,
})?;
crate::core::agent_manifest::atomic_write(&self.path, &body)
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", self.path.display()))?;
std::fs::write(self.scoped_marker(), b"")
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", self.scoped_marker().display()))
}
}