use std::collections::BTreeMap;
use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::DocRef;
use crate::error::Result;
use crate::model::{now_ms, UnixMillis};
pub const PULLS_REF: &str = "refs/gunnar/pulls";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PullState {
Open,
Merged,
Closed,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PullRecord {
pub number: u64,
pub title: String,
pub description: String,
pub source_ref: String,
pub target_ref: String,
pub state: PullState,
pub author: String,
pub created_ms: UnixMillis,
pub updated_ms: UnixMillis,
pub source_oid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_oid: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct RepoPulls {
next: u64,
items: BTreeMap<u64, PullRecord>,
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PullsDoc {
repos: BTreeMap<String, RepoPulls>,
}
impl crate::doc::Doc for PullsDoc {
fn bind(&mut self, _store: &Arc<dyn ControlStore>) {}
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Transition {
Done(PullRecord),
NotFound,
NotOpen(PullState),
}
#[derive(Clone)]
pub struct Pulls {
doc: DocRef<PullsDoc>,
}
impl std::fmt::Debug for Pulls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Pulls")
}
}
impl Pulls {
pub fn new(control: Arc<dyn ControlStore>) -> Result<Self> {
Ok(Pulls {
doc: DocRef::new(control, PULLS_REF)?,
})
}
#[allow(clippy::too_many_arguments)]
pub fn create(
&self,
repo: &str,
author: &str,
title: &str,
description: &str,
source_ref: &str,
target_ref: &str,
source_oid: &str,
) -> Result<PullRecord> {
let now = now_ms();
self.doc.update(|doc| {
let pulls = doc.repos.entry(repo.to_owned()).or_default();
pulls.next += 1;
let record = PullRecord {
number: pulls.next,
title: title.to_owned(),
description: description.to_owned(),
source_ref: source_ref.to_owned(),
target_ref: target_ref.to_owned(),
state: PullState::Open,
author: author.to_owned(),
created_ms: now,
updated_ms: now,
source_oid: source_oid.to_owned(),
merge_oid: None,
};
pulls.items.insert(record.number, record.clone());
Ok(record)
})
}
pub fn list(&self, repo: &str, state: Option<PullState>) -> Result<Vec<PullRecord>> {
let doc = self.doc.load()?;
Ok(doc
.repos
.get(repo)
.map(|pulls| {
pulls
.items
.values()
.rev()
.filter(|record| state.is_none_or(|s| record.state == s))
.cloned()
.collect()
})
.unwrap_or_default())
}
pub fn get(&self, repo: &str, number: u64) -> Result<Option<PullRecord>> {
let doc = self.doc.load()?;
Ok(doc
.repos
.get(repo)
.and_then(|pulls| pulls.items.get(&number))
.cloned())
}
pub fn close(&self, repo: &str, number: u64) -> Result<Transition> {
self.transition(repo, number, |record| {
record.state = PullState::Closed;
})
}
pub fn mark_merged(&self, repo: &str, number: u64, merge_oid: &str) -> Result<Transition> {
let merge_oid = merge_oid.to_owned();
self.transition(repo, number, move |record| {
record.state = PullState::Merged;
record.merge_oid = Some(merge_oid.clone());
})
}
fn transition(
&self,
repo: &str,
number: u64,
edit: impl Fn(&mut PullRecord) + Clone,
) -> Result<Transition> {
let now = now_ms();
self.doc.update(move |doc| {
let Some(pulls) = doc.repos.get_mut(repo) else {
return Ok(Transition::NotFound);
};
let Some(record) = pulls.items.get_mut(&number) else {
return Ok(Transition::NotFound);
};
if record.state != PullState::Open {
return Ok(Transition::NotOpen(record.state));
}
edit(record);
record.updated_ms = now;
Ok(Transition::Done(record.clone()))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::memory_control_store;
fn pulls() -> Pulls {
Pulls::new(memory_control_store().unwrap()).expect("an in-memory pulls table")
}
#[test]
fn numbers_are_minted_per_repository_and_never_reused() {
let table = pulls();
let first = table
.create(
"a/x",
"alice",
"one",
"",
"refs/heads/f",
"refs/heads/main",
"aa",
)
.unwrap();
let second = table
.create(
"a/x",
"alice",
"two",
"",
"refs/heads/g",
"refs/heads/main",
"bb",
)
.unwrap();
let other = table
.create(
"a/y",
"alice",
"other repo",
"",
"refs/heads/f",
"refs/heads/main",
"cc",
)
.unwrap();
assert_eq!((first.number, second.number), (1, 2));
assert_eq!(other.number, 1, "numbers do not leak across repositories");
assert!(matches!(
table.close("a/x", 1).unwrap(),
Transition::Done(_)
));
let third = table
.create(
"a/x",
"alice",
"three",
"",
"refs/heads/h",
"refs/heads/main",
"dd",
)
.unwrap();
assert_eq!(third.number, 3, "a closed number is spent, not recycled");
}
#[test]
fn a_pull_leaves_open_exactly_once() {
let table = pulls();
table
.create(
"a/x",
"alice",
"t",
"",
"refs/heads/f",
"refs/heads/main",
"aa",
)
.unwrap();
let merged = table.mark_merged("a/x", 1, "feedbeef").unwrap();
let Transition::Done(record) = merged else {
panic!("first transition must land: {merged:?}");
};
assert_eq!(record.state, PullState::Merged);
assert_eq!(record.merge_oid.as_deref(), Some("feedbeef"));
assert_eq!(
table.close("a/x", 1).unwrap(),
Transition::NotOpen(PullState::Merged),
"closing a merged pull must say it is merged, not close it"
);
assert_eq!(table.close("a/x", 99).unwrap(), Transition::NotFound);
}
#[test]
fn the_list_is_newest_first_and_filterable() {
let table = pulls();
for title in ["one", "two", "three"] {
table
.create(
"a/x",
"alice",
title,
"",
"refs/heads/f",
"refs/heads/main",
"aa",
)
.unwrap();
}
table.close("a/x", 2).unwrap();
let all = table.list("a/x", None).unwrap();
assert_eq!(
all.iter().map(|p| p.number).collect::<Vec<_>>(),
vec![3, 2, 1]
);
let open = table.list("a/x", Some(PullState::Open)).unwrap();
assert_eq!(
open.iter().map(|p| p.number).collect::<Vec<_>>(),
vec![3, 1]
);
assert!(table.list("a/nothing", None).unwrap().is_empty());
}
}