Skip to main content

gunnar_core/
pulls.rs

1//! **Pull requests: the record half.**
2//!
3//! One control document, [`PULLS_REF`], holding every pull request on the
4//! server — the same [`DocRef`] CAS discipline as the catalog and the PKI
5//! document, so two concurrent `Create`s cannot mint one number twice and a
6//! `Merge` racing a `Close` loses honestly instead of both winning.
7//!
8//! This module stores and transitions records. It never touches a ref: the
9//! merge's ref move lives in the server's control plane, next to the ancestry
10//! check and the store handle it needs — a record that SAYS merged is written
11//! only after the ref HAS moved.
12//!
13//! # Not sharded, on the PKI document's reasoning
14//!
15//! [`crate::AuthTable`]'s maps are sharded because a measurement found a whole-table
16//! parse per connection. This document is read when somebody opens a pull
17//! request page, not per connection, and a forge's PR count grows like its
18//! repo count, not like its traffic. The day `list` shows up in a profile,
19//! shard it then — the storage is content-addressed either way.
20
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24use crate::control::ControlStore;
25use crate::doc::DocRef;
26use crate::error::Result;
27use crate::model::{now_ms, UnixMillis};
28
29/// The control document holding every pull request. `refs/gunnar/…` like
30/// [`crate::CATALOG_REF`]: not a git reference since the certificate-identity plan, but the name
31/// every log line and runbook uses.
32pub const PULLS_REF: &str = "refs/gunnar/pulls";
33
34/// Where a pull request is in its life.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PullState {
38    /// Reviewable and mergeable.
39    Open,
40    /// The target ref moved to the source tip; terminal.
41    Merged,
42    /// Declined without merging; terminal.
43    Closed,
44}
45
46/// One pull request, as stored.
47#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
48pub struct PullRecord {
49    /// Per-repository, dense, never reused.
50    pub number: u64,
51    /// The one line every list shows.
52    pub title: String,
53    /// Free text; may be empty.
54    pub description: String,
55    /// Full ref names, both of them. Short names are a client convenience and
56    /// a stored short name is an ambiguity someone resolves differently later.
57    pub source_ref: String,
58    /// The ref a merge moves. Full name, for the reason above it.
59    pub target_ref: String,
60    /// Where in the lifecycle this record stands.
61    pub state: PullState,
62    /// The ACL principal that opened it.
63    pub author: String,
64    /// When it was opened. Never rewritten.
65    pub created_ms: UnixMillis,
66    /// When any field above last changed, including [`Self::state`].
67    pub updated_ms: UnixMillis,
68    /// The source tip AT CREATION — informational, a review anchor, never the
69    /// oid a merge moves to (the branch may advance after opening).
70    pub source_oid: String,
71    /// The oid the target ref moved to. `None` unless [`PullState::Merged`].
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub merge_oid: Option<String>,
74}
75
76/// One repository's pulls: its counter and its records.
77#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
78struct RepoPulls {
79    /// The last number handed out. Numbers are per-repository and never
80    /// reused — a closed #3 keeps its name forever.
81    next: u64,
82    items: BTreeMap<u64, PullRecord>,
83}
84
85/// The stored document: every repository's pulls, keyed `<namespace>/<name>`.
86#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
87#[serde(deny_unknown_fields)]
88struct PullsDoc {
89    repos: BTreeMap<String, RepoPulls>,
90}
91
92impl crate::doc::Doc for PullsDoc {
93    fn bind(&mut self, _store: &Arc<dyn ControlStore>) {}
94    fn flush(&mut self) -> Result<()> {
95        Ok(())
96    }
97}
98
99/// What a state transition found. The caller names the refusal; this module
100/// only reports what is true.
101#[derive(Debug, Clone, PartialEq)]
102pub enum Transition {
103    /// The transition landed; here is the record as written.
104    Done(PullRecord),
105    /// No pull with that number in that repository.
106    NotFound,
107    /// The pull exists and is not open — carries what it is instead, so the
108    /// refusal can say "already merged" rather than "cannot".
109    NotOpen(PullState),
110}
111
112/// The pull-request table. Cheap to clone; every clone is the same document.
113#[derive(Clone)]
114pub struct Pulls {
115    doc: DocRef<PullsDoc>,
116}
117
118impl std::fmt::Debug for Pulls {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.write_str("Pulls")
121    }
122}
123
124impl Pulls {
125    /// Open the table held in `control`.
126    pub fn new(control: Arc<dyn ControlStore>) -> Result<Self> {
127        Ok(Pulls {
128            doc: DocRef::new(control, PULLS_REF)?,
129        })
130    }
131
132    /// Open a pull request. The number is minted inside the document CAS, so
133    /// two racing creates get two numbers.
134    #[allow(clippy::too_many_arguments)]
135    pub fn create(
136        &self,
137        repo: &str,
138        author: &str,
139        title: &str,
140        description: &str,
141        source_ref: &str,
142        target_ref: &str,
143        source_oid: &str,
144    ) -> Result<PullRecord> {
145        let now = now_ms();
146        self.doc.update(|doc| {
147            let pulls = doc.repos.entry(repo.to_owned()).or_default();
148            pulls.next += 1;
149            let record = PullRecord {
150                number: pulls.next,
151                title: title.to_owned(),
152                description: description.to_owned(),
153                source_ref: source_ref.to_owned(),
154                target_ref: target_ref.to_owned(),
155                state: PullState::Open,
156                author: author.to_owned(),
157                created_ms: now,
158                updated_ms: now,
159                source_oid: source_oid.to_owned(),
160                merge_oid: None,
161            };
162            pulls.items.insert(record.number, record.clone());
163            Ok(record)
164        })
165    }
166
167    /// Every pull in one repository, newest first, optionally one state only.
168    pub fn list(&self, repo: &str, state: Option<PullState>) -> Result<Vec<PullRecord>> {
169        let doc = self.doc.load()?;
170        Ok(doc
171            .repos
172            .get(repo)
173            .map(|pulls| {
174                pulls
175                    .items
176                    .values()
177                    .rev()
178                    .filter(|record| state.is_none_or(|s| record.state == s))
179                    .cloned()
180                    .collect()
181            })
182            .unwrap_or_default())
183    }
184
185    /// One pull, or `None`.
186    pub fn get(&self, repo: &str, number: u64) -> Result<Option<PullRecord>> {
187        let doc = self.doc.load()?;
188        Ok(doc
189            .repos
190            .get(repo)
191            .and_then(|pulls| pulls.items.get(&number))
192            .cloned())
193    }
194
195    /// Close an open pull. Never a merge: closing records a decision NOT to.
196    pub fn close(&self, repo: &str, number: u64) -> Result<Transition> {
197        self.transition(repo, number, |record| {
198            record.state = PullState::Closed;
199        })
200    }
201
202    /// Record that the target ref has ALREADY moved to `merge_oid`.
203    ///
204    /// Called after the ref update commits, never before: a record that says
205    /// merged while the CAS lost its race is a lie with an audit trail.
206    pub fn mark_merged(&self, repo: &str, number: u64, merge_oid: &str) -> Result<Transition> {
207        let merge_oid = merge_oid.to_owned();
208        self.transition(repo, number, move |record| {
209            record.state = PullState::Merged;
210            record.merge_oid = Some(merge_oid.clone());
211        })
212    }
213
214    fn transition(
215        &self,
216        repo: &str,
217        number: u64,
218        edit: impl Fn(&mut PullRecord) + Clone,
219    ) -> Result<Transition> {
220        let now = now_ms();
221        self.doc.update(move |doc| {
222            let Some(pulls) = doc.repos.get_mut(repo) else {
223                return Ok(Transition::NotFound);
224            };
225            let Some(record) = pulls.items.get_mut(&number) else {
226                return Ok(Transition::NotFound);
227            };
228            if record.state != PullState::Open {
229                return Ok(Transition::NotOpen(record.state));
230            }
231            edit(record);
232            record.updated_ms = now;
233            Ok(Transition::Done(record.clone()))
234        })
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::control::memory_control_store;
242
243    fn pulls() -> Pulls {
244        Pulls::new(memory_control_store().unwrap()).expect("an in-memory pulls table")
245    }
246
247    /// ★ Numbers are per-repository, dense, and never reused — a closed pull
248    /// keeps its name and the next one does not fill the hole.
249    #[test]
250    fn numbers_are_minted_per_repository_and_never_reused() {
251        let table = pulls();
252        let first = table
253            .create(
254                "a/x",
255                "alice",
256                "one",
257                "",
258                "refs/heads/f",
259                "refs/heads/main",
260                "aa",
261            )
262            .unwrap();
263        let second = table
264            .create(
265                "a/x",
266                "alice",
267                "two",
268                "",
269                "refs/heads/g",
270                "refs/heads/main",
271                "bb",
272            )
273            .unwrap();
274        let other = table
275            .create(
276                "a/y",
277                "alice",
278                "other repo",
279                "",
280                "refs/heads/f",
281                "refs/heads/main",
282                "cc",
283            )
284            .unwrap();
285        assert_eq!((first.number, second.number), (1, 2));
286        assert_eq!(other.number, 1, "numbers do not leak across repositories");
287
288        assert!(matches!(
289            table.close("a/x", 1).unwrap(),
290            Transition::Done(_)
291        ));
292        let third = table
293            .create(
294                "a/x",
295                "alice",
296                "three",
297                "",
298                "refs/heads/h",
299                "refs/heads/main",
300                "dd",
301            )
302            .unwrap();
303        assert_eq!(third.number, 3, "a closed number is spent, not recycled");
304    }
305
306    /// ★ A pull leaves Open exactly once. The second transition reports what
307    /// the pull already is, so the refusal can name it.
308    #[test]
309    fn a_pull_leaves_open_exactly_once() {
310        let table = pulls();
311        table
312            .create(
313                "a/x",
314                "alice",
315                "t",
316                "",
317                "refs/heads/f",
318                "refs/heads/main",
319                "aa",
320            )
321            .unwrap();
322        let merged = table.mark_merged("a/x", 1, "feedbeef").unwrap();
323        let Transition::Done(record) = merged else {
324            panic!("first transition must land: {merged:?}");
325        };
326        assert_eq!(record.state, PullState::Merged);
327        assert_eq!(record.merge_oid.as_deref(), Some("feedbeef"));
328
329        assert_eq!(
330            table.close("a/x", 1).unwrap(),
331            Transition::NotOpen(PullState::Merged),
332            "closing a merged pull must say it is merged, not close it"
333        );
334        assert_eq!(table.close("a/x", 99).unwrap(), Transition::NotFound);
335    }
336
337    /// ★ The list is newest-first and the state filter filters.
338    #[test]
339    fn the_list_is_newest_first_and_filterable() {
340        let table = pulls();
341        for title in ["one", "two", "three"] {
342            table
343                .create(
344                    "a/x",
345                    "alice",
346                    title,
347                    "",
348                    "refs/heads/f",
349                    "refs/heads/main",
350                    "aa",
351                )
352                .unwrap();
353        }
354        table.close("a/x", 2).unwrap();
355        let all = table.list("a/x", None).unwrap();
356        assert_eq!(
357            all.iter().map(|p| p.number).collect::<Vec<_>>(),
358            vec![3, 2, 1]
359        );
360        let open = table.list("a/x", Some(PullState::Open)).unwrap();
361        assert_eq!(
362            open.iter().map(|p| p.number).collect::<Vec<_>>(),
363            vec![3, 1]
364        );
365        assert!(table.list("a/nothing", None).unwrap().is_empty());
366    }
367}