use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::github::detail::{IssueDetail, PrDetail};
pub type CommitPatchMap = HashMap<String, Option<String>>;
pub const CACHE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct Cached<T> {
pub data: T,
pub fetched_at: Instant,
}
impl<T> Cached<T> {
pub fn new(data: T) -> Self {
Self { data, fetched_at: Instant::now() }
}
pub fn age(&self) -> Duration {
self.fetched_at.elapsed()
}
pub fn is_fresh(&self) -> bool {
self.age() < CACHE_TTL
}
}
#[derive(Debug, Default)]
pub struct DetailCache {
pub(crate) prs: HashMap<(String, u32), Cached<PrDetail>>,
pub(crate) issues: HashMap<(String, u32), Cached<IssueDetail>>,
pub(crate) commit_patches: HashMap<(String, String), Cached<CommitPatchMap>>,
}
impl DetailCache {
pub fn new() -> Self {
Self::default()
}
pub fn insert_pr(&mut self, detail: PrDetail) {
let key = (detail.repo.clone(), detail.number);
self.prs.insert(key, Cached::new(detail));
}
pub fn insert_issue(&mut self, detail: IssueDetail) {
let key = (detail.repo.clone(), detail.number);
self.issues.insert(key, Cached::new(detail));
}
pub fn get_pr(&self, repo: &str, number: u32) -> Option<&Cached<PrDetail>> {
self.prs.get(&(repo.to_owned(), number))
}
pub fn get_issue(&self, repo: &str, number: u32) -> Option<&Cached<IssueDetail>> {
self.issues.get(&(repo.to_owned(), number))
}
pub fn invalidate_pr(&mut self, repo: &str, number: u32) {
self.prs.remove(&(repo.to_owned(), number));
}
pub fn invalidate_issue(&mut self, repo: &str, number: u32) {
self.issues.remove(&(repo.to_owned(), number));
}
pub fn get_commit_patches(&self, repo: &str, sha: &str) -> Option<&Cached<CommitPatchMap>> {
self.commit_patches.get(&(repo.to_owned(), sha.to_owned()))
}
pub fn insert_commit_patches(&mut self, repo: String, sha: String, map: CommitPatchMap) {
self.commit_patches.insert((repo, sha), Cached::new(map));
}
pub fn prune_stale_commits(&mut self, repo: &str, current_shas: &[String]) {
self.commit_patches.retain(|(r, sha), _| r != repo || current_shas.contains(sha));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::detail::{IssueDetail, PrDetail};
use chrono::Utc;
fn make_pr_detail(repo: &str, number: u32) -> PrDetail {
PrDetail {
node_id: "PR_node".to_owned(),
repo: repo.to_owned(),
number,
title: "Test PR".to_owned(),
url: format!("https://github.com/{repo}/pull/{number}"),
author: "user".to_owned(),
body_markdown: String::new(),
base_ref: "main".to_owned(),
head_ref: "feat/x".to_owned(),
head_oid: "0123456789abcdef0123456789abcdef01234567".to_owned(),
is_draft: false,
additions: 0,
deletions: 0,
changed_files_count: 0,
updated_at: Utc::now(),
created_at: Utc::now(),
merged: false,
files: vec![],
check_runs: vec![],
reviews: vec![],
review_threads: vec![],
issue_comments: vec![],
commits: vec![],
}
}
fn make_issue_detail(repo: &str, number: u32) -> IssueDetail {
IssueDetail {
node_id: "ISSUE_node".to_owned(),
repo: repo.to_owned(),
number,
title: "Test Issue".to_owned(),
url: format!("https://github.com/{repo}/issues/{number}"),
author: "user".to_owned(),
body_markdown: String::new(),
state: "OPEN".to_owned(),
updated_at: Utc::now(),
created_at: Utc::now(),
labels: vec![],
assignees: vec![],
comments: vec![],
}
}
#[test]
#[allow(clippy::expect_used)]
fn cache_insert_and_get_pr() {
let mut cache = DetailCache::new();
let detail = make_pr_detail("o/r", 42);
cache.insert_pr(detail.clone());
let hit = cache.get_pr("o/r", 42).expect("should be a cache hit");
assert_eq!(hit.data.number, 42);
assert_eq!(hit.data.repo, "o/r");
}
#[test]
#[allow(clippy::expect_used)]
fn cache_insert_and_get_issue() {
let mut cache = DetailCache::new();
let detail = make_issue_detail("o/r", 7);
cache.insert_issue(detail.clone());
let hit = cache.get_issue("o/r", 7).expect("should be a cache hit");
assert_eq!(hit.data.number, 7);
}
#[test]
fn cache_is_fresh_true_under_ttl_false_after() {
let data = make_pr_detail("o/r", 1);
let fresh = Cached::new(data.clone());
assert!(fresh.is_fresh(), "entry stamped now must be fresh");
let stale = Cached {
data,
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(CACHE_TTL.as_secs() + 1))
.unwrap_or_else(Instant::now),
};
assert!(!stale.is_fresh(), "entry older than TTL must be stale");
}
#[test]
fn cache_invalidate_pr_removes_entry() {
let mut cache = DetailCache::new();
cache.insert_pr(make_pr_detail("o/r", 5));
cache.invalidate_pr("o/r", 5);
assert!(cache.get_pr("o/r", 5).is_none(), "invalidated entry must not be present");
}
#[test]
fn cache_miss_on_unknown_key() {
let cache = DetailCache::new();
assert!(cache.get_pr("x/y", 999).is_none());
assert!(cache.get_issue("x/y", 999).is_none());
}
}