use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use argonauts_conductor::gh::RepoName;
use argonauts_core::RunId;
use serde::{Deserialize, Serialize};
use super::IssueLink;
use super::watch::ToAnswer;
use crate::engine::host::Host;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Followed {
pub run: RunId,
pub repo: RepoName,
pub number: u64,
pub url: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issue: Option<u64>,
#[serde(default)]
pub rounds: u32,
#[serde(default)]
pub more_rounds: u32,
#[serde(default)]
pub writes_allowed: bool,
#[serde(default)]
pub ended: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worked_at: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub answered: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<Due>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub held: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Reply>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set_aside: Option<String>,
#[serde(default)]
pub issue_told: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub said: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heading: Option<Heading>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Heading {
pub said: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub then: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Due {
pub work: Work,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixed: Option<String>,
#[serde(default)]
pub reviewed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Reply>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Work {
Check { check: String, log: String },
Comments { comments: Vec<ToAnswer> },
}
impl Work {
pub fn what(&self) -> &'static str {
match self {
Work::Check { .. } => "ci",
Work::Comments { .. } => "comments",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Reply {
pub to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread: Option<u64>,
pub body: String,
}
impl Followed {
pub fn new(run: RunId, repo: RepoName, number: u64, url: String, branch: String) -> Self {
Self {
run,
repo,
number,
url,
branch,
remote: None,
issue: None,
rounds: 0,
more_rounds: 0,
writes_allowed: false,
ended: false,
head: None,
worked_at: None,
answered: Vec::new(),
due: None,
held: None,
replies: Vec::new(),
set_aside: None,
issue_told: false,
said: None,
heading: None,
}
}
pub fn opened(host: &Host, link: &IssueLink, url: &str, head: Option<String>) -> Option<Self> {
let number = number_of(url)?;
Some(Self {
issue: Some(link.number),
writes_allowed: link.writes_allowed,
remote: link.push_remote.clone(),
head,
..Self::new(
host.run_id().clone(),
link.repo.clone(),
number,
url.to_owned(),
link.branch.clone(),
)
})
}
pub fn rounds_allowed(&self, max_rounds: u32) -> u32 {
max_rounds.saturating_add(self.more_rounds)
}
pub fn dir(state_dir: &Path) -> PathBuf {
state_dir.join("pull_requests")
}
fn path(&self, state_dir: &Path) -> PathBuf {
Self::dir(state_dir).join(format!("{}.json", self.number))
}
pub fn save(&self, state_dir: &Path) -> io::Result<()> {
fs::create_dir_all(Self::dir(state_dir))?;
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
let path = self.path(state_dir);
let partial = path.with_extension("json.partial");
fs::write(&partial, text)?;
fs::rename(partial, path)
}
pub fn open(state_dir: &Path) -> Vec<Self> {
let Ok(entries) = fs::read_dir(Self::dir(state_dir)) else {
return Vec::new();
};
let mut all: Vec<Self> = entries
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|entry| fs::read_to_string(entry.path()).ok())
.filter_map(|text| serde_json::from_str::<Self>(&text).ok())
.filter(|followed| !followed.ended)
.collect();
all.sort_by_key(|followed| followed.number);
all
}
pub fn of_run(state_dir: &Path, run: &RunId) -> Option<Self> {
Self::open(state_dir)
.into_iter()
.find(|followed| &followed.run == run)
}
}
fn number_of(url: &str) -> Option<u64> {
let (_, number) = url.trim_end_matches('/').rsplit_once("/pull/")?;
number.split(['/', '#', '?']).next()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn followed(number: u64, run: u32, ended: bool) -> Followed {
Followed {
issue: Some(42),
ended,
..Followed::new(
RunId::new(format!("run-0{run}")).unwrap(),
RepoName::parse("test-user/limiter").unwrap(),
number,
format!("https://github.com/test-user/limiter/pull/{number}"),
"issue-42-per-key-limiter".to_owned(),
)
}
}
#[test]
fn a_pull_requests_number_is_the_end_of_its_address() {
assert_eq!(number_of("https://github.com/team/app/pull/57"), Some(57));
assert_eq!(
number_of("https://github.com/team/app/pull/57/files"),
Some(57)
);
assert_eq!(number_of("https://github.com/team/app/issues/57"), None);
}
#[test]
fn only_those_still_open_are_followed() {
let state = tempfile::tempdir().unwrap();
followed(57, 1, false).save(state.path()).unwrap();
followed(58, 2, true).save(state.path()).unwrap();
followed(51, 3, false).save(state.path()).unwrap();
let numbers: Vec<u64> = Followed::open(state.path())
.iter()
.map(|followed| followed.number)
.collect();
assert_eq!(numbers, [51, 57]);
assert_eq!(
Followed::of_run(state.path(), &RunId::new("run-01").unwrap()).map(|f| f.number),
Some(57)
);
assert!(Followed::of_run(state.path(), &RunId::new("run-02").unwrap()).is_none());
}
}