gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! **Pull requests: the record half.**
//!
//! One control document, [`PULLS_REF`], holding every pull request on the
//! server — the same [`DocRef`] CAS discipline as the catalog and the PKI
//! document, so two concurrent `Create`s cannot mint one number twice and a
//! `Merge` racing a `Close` loses honestly instead of both winning.
//!
//! This module stores and transitions records. It never touches a ref: the
//! merge's ref move lives in the server's control plane, next to the ancestry
//! check and the store handle it needs — a record that SAYS merged is written
//! only after the ref HAS moved.
//!
//! # Not sharded, on the PKI document's reasoning
//!
//! [`crate::AuthTable`]'s maps are sharded because a measurement found a whole-table
//! parse per connection. This document is read when somebody opens a pull
//! request page, not per connection, and a forge's PR count grows like its
//! repo count, not like its traffic. The day `list` shows up in a profile,
//! shard it then — the storage is content-addressed either way.

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};

/// The control document holding every pull request. `refs/gunnar/…` like
/// [`crate::CATALOG_REF`]: not a git reference since the certificate-identity plan, but the name
/// every log line and runbook uses.
pub const PULLS_REF: &str = "refs/gunnar/pulls";

/// Where a pull request is in its life.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PullState {
    /// Reviewable and mergeable.
    Open,
    /// The target ref moved to the source tip; terminal.
    Merged,
    /// Declined without merging; terminal.
    Closed,
}

/// One pull request, as stored.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PullRecord {
    /// Per-repository, dense, never reused.
    pub number: u64,
    /// The one line every list shows.
    pub title: String,
    /// Free text; may be empty.
    pub description: String,
    /// Full ref names, both of them. Short names are a client convenience and
    /// a stored short name is an ambiguity someone resolves differently later.
    pub source_ref: String,
    /// The ref a merge moves. Full name, for the reason above it.
    pub target_ref: String,
    /// Where in the lifecycle this record stands.
    pub state: PullState,
    /// The ACL principal that opened it.
    pub author: String,
    /// When it was opened. Never rewritten.
    pub created_ms: UnixMillis,
    /// When any field above last changed, including [`Self::state`].
    pub updated_ms: UnixMillis,
    /// The source tip AT CREATION — informational, a review anchor, never the
    /// oid a merge moves to (the branch may advance after opening).
    pub source_oid: String,
    /// The oid the target ref moved to. `None` unless [`PullState::Merged`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub merge_oid: Option<String>,
}

/// One repository's pulls: its counter and its records.
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct RepoPulls {
    /// The last number handed out. Numbers are per-repository and never
    /// reused — a closed #3 keeps its name forever.
    next: u64,
    items: BTreeMap<u64, PullRecord>,
}

/// The stored document: every repository's pulls, keyed `<namespace>/<name>`.
#[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(())
    }
}

/// What a state transition found. The caller names the refusal; this module
/// only reports what is true.
#[derive(Debug, Clone, PartialEq)]
pub enum Transition {
    /// The transition landed; here is the record as written.
    Done(PullRecord),
    /// No pull with that number in that repository.
    NotFound,
    /// The pull exists and is not open — carries what it is instead, so the
    /// refusal can say "already merged" rather than "cannot".
    NotOpen(PullState),
}

/// The pull-request table. Cheap to clone; every clone is the same document.
#[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 {
    /// Open the table held in `control`.
    pub fn new(control: Arc<dyn ControlStore>) -> Result<Self> {
        Ok(Pulls {
            doc: DocRef::new(control, PULLS_REF)?,
        })
    }

    /// Open a pull request. The number is minted inside the document CAS, so
    /// two racing creates get two numbers.
    #[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)
        })
    }

    /// Every pull in one repository, newest first, optionally one state only.
    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())
    }

    /// One pull, or `None`.
    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())
    }

    /// Close an open pull. Never a merge: closing records a decision NOT to.
    pub fn close(&self, repo: &str, number: u64) -> Result<Transition> {
        self.transition(repo, number, |record| {
            record.state = PullState::Closed;
        })
    }

    /// Record that the target ref has ALREADY moved to `merge_oid`.
    ///
    /// Called after the ref update commits, never before: a record that says
    /// merged while the CAS lost its race is a lie with an audit trail.
    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")
    }

    /// ★ Numbers are per-repository, dense, and never reused — a closed pull
    /// keeps its name and the next one does not fill the hole.
    #[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");
    }

    /// ★ A pull leaves Open exactly once. The second transition reports what
    /// the pull already is, so the refusal can name it.
    #[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);
    }

    /// ★ The list is newest-first and the state filter filters.
    #[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());
    }
}