1use 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
29pub const PULLS_REF: &str = "refs/gunnar/pulls";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PullState {
38 Open,
40 Merged,
42 Closed,
44}
45
46#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
48pub struct PullRecord {
49 pub number: u64,
51 pub title: String,
53 pub description: String,
55 pub source_ref: String,
58 pub target_ref: String,
60 pub state: PullState,
62 pub author: String,
64 pub created_ms: UnixMillis,
66 pub updated_ms: UnixMillis,
68 pub source_oid: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub merge_oid: Option<String>,
74}
75
76#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
78struct RepoPulls {
79 next: u64,
82 items: BTreeMap<u64, PullRecord>,
83}
84
85#[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#[derive(Debug, Clone, PartialEq)]
102pub enum Transition {
103 Done(PullRecord),
105 NotFound,
107 NotOpen(PullState),
110}
111
112#[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 pub fn new(control: Arc<dyn ControlStore>) -> Result<Self> {
127 Ok(Pulls {
128 doc: DocRef::new(control, PULLS_REF)?,
129 })
130 }
131
132 #[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 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 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 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 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 #[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 #[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 #[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}