Skip to main content

review_queue/
state.rs

1//! `state.json`: the single source of truth for which reviews have a workspace,
2//! where it lives, and whether it's safe for `sync` to touch it.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::config::VcsKind;
11use crate::source::{RepoRef, ReviewKind};
12
13/// `{source}/{id}`, e.g. `phab/D12345` or `gh/mozilla/taskgraph/123`. `source` is the configured
14/// source's name (used to look up its `ReviewSource` impl); `id` is that source's own review id.
15/// Used as the state map key and, via `Paths::workspace_dir`, the workspace directory name - the
16/// full slug (not just `id`) is used there so ids can't collide across sources sharing the same
17/// canonical repo.
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
19pub struct ReviewKey {
20    pub source: String,
21    pub id: String,
22}
23
24impl ReviewKey {
25    pub fn new(source: impl Into<String>, id: impl Into<String>) -> Self {
26        Self {
27            source: source.into(),
28            id: id.into(),
29        }
30    }
31
32    /// Directory-safe join used for state.json keys and workspace subpaths.
33    pub fn slug(&self) -> String {
34        format!("{}/{}", self.source, self.id)
35    }
36}
37
38impl std::fmt::Display for ReviewKey {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.slug())
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Status {
47    /// Checked out cleanly and matches the latest version from the source.
48    Ready,
49    /// A patch in the stack failed to apply; the workspace is left as-is for inspection.
50    ApplyFailed,
51    /// Local modifications (or a HEAD that doesn't match state) prevented an update or removal.
52    Dirty,
53}
54
55/// A local worktree/workspace fetched for a stack of reviews (a lone review is a stack of one) -
56/// absent until the user explicitly asks for one (`rq fetch`, or the open-locally hotkey in `rq
57/// show`'s TUI), since `sync` no longer creates these on its own. Stored in `State::workspaces`,
58/// keyed by a stable stack id; member reviews point at it via `ReviewEntry::stack_id`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Workspace {
61    /// Canonical repo this workspace was created from (may be a tool-managed clone or a
62    /// discovered workdir checkout). Kept even if a later scan points elsewhere.
63    pub repo_path: PathBuf,
64    pub vcs: VcsKind,
65    pub workspace_path: PathBuf,
66    /// The stack tip's commit id, i.e. what the workspace was built to. The user may have moved
67    /// to an earlier patch in the stack since (see `Vcs::position`) - that isn't "dirty".
68    pub head_id: String,
69    pub status: Status,
70    /// The stack's top-most review as of the last build. A different tip (a new patch landed on
71    /// top of the stack) means the workspace needs rebuilding.
72    pub tip: ReviewKey,
73    /// The tip's `version` as of the last build; a change means some patch in the stack changed.
74    pub version: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ReviewEntry {
79    pub key: ReviewKey,
80    pub title: String,
81    pub author: String,
82    pub url: String,
83    /// The review's repo, as reported by its source - kept even without a workspace so `rq
84    /// fetch`/the TUI can resolve a canonical repo later without re-querying the source.
85    pub repo: RepoRef,
86    pub kind: ReviewKind,
87    /// Diff ID / head SHA (or comma-joined stack of diff IDs for Phabricator) last seen from the
88    /// source - independent of whether a workspace has caught up to it.
89    pub version: String,
90    /// True while the review is waiting on you; false once you've acted (e.g. requested
91    /// changes) but the review itself hasn't resolved yet.
92    pub in_queue: bool,
93    /// The review itself landed/closed/merged/abandoned (as opposed to just dropping out of your
94    /// queue). A resolved review with no workspace is dropped on the next sync; one with a
95    /// workspace has that workspace removed too, even if it has local changes.
96    pub resolved: bool,
97    pub last_synced: chrono::DateTime<chrono::Utc>,
98    /// Id of the `State::workspaces` entry this review's checked out in, if it's been fetched.
99    /// Every member of a stack shares one.
100    #[serde(default)]
101    pub stack_id: Option<String>,
102    /// The review's ancestors within its stack, bottom-most first, excluding itself and any
103    /// already-landed ones. Ancestors needn't be tracked themselves (you may not be a reviewer
104    /// on them).
105    #[serde(default)]
106    pub ancestors: Vec<ReviewKey>,
107    /// Diffstat fetched from the source as of `last_synced` (same summary format `git diff
108    /// --stat`/`jj diff --stat` print) - `None` if the source couldn't produce one. Refreshed on
109    /// every `rq sync`, independent of whether a local workspace exists.
110    pub diff_stat: Option<String>,
111    /// PR description / revision summary as of `last_synced`, refreshed on every `rq sync`. `None`
112    /// for entries synced before this was tracked, or if the source has none.
113    #[serde(default)]
114    pub description: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct State {
119    reviews: BTreeMap<String, ReviewEntry>,
120    #[serde(default)]
121    workspaces: BTreeMap<String, Workspace>,
122}
123
124impl State {
125    pub fn load(path: &Path) -> Result<Self> {
126        if !path.exists() {
127            return Ok(Self::default());
128        }
129        let text = std::fs::read_to_string(path)
130            .with_context(|| format!("reading state {}", path.display()))?;
131        if text.trim().is_empty() {
132            return Ok(Self::default());
133        }
134        let mut value: serde_json::Value = serde_json::from_str(&text)
135            .with_context(|| format!("parsing state {}", path.display()))?;
136        migrate_per_review_workspaces(&mut value);
137        serde_json::from_value(value).with_context(|| format!("parsing state {}", path.display()))
138    }
139
140    /// Atomic write: temp file in the same directory, then rename over the target.
141    pub fn save(&self, path: &Path) -> Result<()> {
142        let dir = path
143            .parent()
144            .context("state path has no parent directory")?;
145        std::fs::create_dir_all(dir)?;
146        let tmp = dir.join(format!(
147            ".{}.tmp",
148            path.file_name().unwrap().to_string_lossy()
149        ));
150        let text = serde_json::to_string_pretty(self)?;
151        std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
152        std::fs::rename(&tmp, path)
153            .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
154        Ok(())
155    }
156
157    pub fn get(&self, key: &ReviewKey) -> Option<&ReviewEntry> {
158        self.reviews.get(&key.slug())
159    }
160
161    pub fn insert(&mut self, entry: ReviewEntry) {
162        self.reviews.insert(entry.key.slug(), entry);
163    }
164
165    pub fn remove(&mut self, key: &ReviewKey) -> Option<ReviewEntry> {
166        self.reviews.remove(&key.slug())
167    }
168
169    pub fn iter(&self) -> impl Iterator<Item = &ReviewEntry> {
170        self.reviews.values()
171    }
172
173    pub fn workspace(&self, stack_id: &str) -> Option<&Workspace> {
174        self.workspaces.get(stack_id)
175    }
176
177    /// The workspace `entry` is checked out in, if any.
178    pub fn workspace_of(&self, entry: &ReviewEntry) -> Option<&Workspace> {
179        entry.stack_id.as_deref().and_then(|id| self.workspace(id))
180    }
181
182    pub fn workspace_for(&self, key: &ReviewKey) -> Option<&Workspace> {
183        self.get(key).and_then(|e| self.workspace_of(e))
184    }
185
186    pub fn workspaces(&self) -> impl Iterator<Item = (&String, &Workspace)> {
187        self.workspaces.iter()
188    }
189
190    pub fn insert_workspace(&mut self, stack_id: String, ws: Workspace) {
191        self.workspaces.insert(stack_id, ws);
192    }
193
194    pub fn remove_workspace(&mut self, stack_id: &str) -> Option<Workspace> {
195        self.workspaces.remove(stack_id)
196    }
197
198    /// Every tracked review checked out in `stack_id`'s workspace.
199    pub fn members_of(&self, stack_id: &str) -> Vec<ReviewKey> {
200        self.reviews
201            .values()
202            .filter(|e| e.stack_id.as_deref() == Some(stack_id))
203            .map(|e| e.key.clone())
204            .collect()
205    }
206
207    /// Apply `f` to every entry (used to detach/attach stack membership in bulk).
208    pub fn for_each_entry_mut(&mut self, mut f: impl FnMut(&mut ReviewEntry)) {
209        self.reviews.values_mut().for_each(&mut f);
210    }
211
212    /// Matches from the front of `id` (`"D123"` -> `"D12345"`), from the front of `id`'s last
213    /// `/`-delimited segment (GitHub ids are `owner/repo/number`, so `"123"` -> `".../123"` -
214    /// otherwise there'd be no way to find a PR by the number you'd actually remember, since it
215    /// sits at the end, not the start), or an exact `source/id` slug.
216    pub fn find_by_prefix<'a>(&'a self, prefix: &str) -> Vec<&'a ReviewEntry> {
217        self.reviews
218            .values()
219            .filter(|e| {
220                let id = &e.key.id;
221                id.starts_with(prefix)
222                    || id
223                        .rsplit('/')
224                        .next()
225                        .is_some_and(|last| last.starts_with(prefix))
226                    || e.key.slug() == prefix
227            })
228            .collect()
229    }
230}
231
232/// Older `state.json` files kept a `workspace` on every review; workspaces now live in a
233/// top-level map shared by a stack's members. Each old one becomes a one-review stack.
234fn migrate_per_review_workspaces(value: &mut serde_json::Value) {
235    use serde_json::Value;
236
237    let Some(reviews) = value.get_mut("reviews").and_then(Value::as_object_mut) else {
238        return;
239    };
240    let mut migrated = serde_json::Map::new();
241    for (slug, entry) in reviews.iter_mut() {
242        let Some(entry) = entry.as_object_mut() else {
243            continue;
244        };
245        let Some(Value::Object(mut ws)) = entry.remove("workspace") else {
246            continue;
247        };
248        ws.insert(
249            "tip".into(),
250            entry.get("key").cloned().unwrap_or(Value::Null),
251        );
252        ws.insert(
253            "version".into(),
254            entry.get("version").cloned().unwrap_or(Value::Null),
255        );
256        entry.insert("stack_id".into(), Value::String(slug.clone()));
257        migrated.insert(slug.clone(), Value::Object(ws));
258    }
259    if migrated.is_empty() {
260        return;
261    }
262    if let Some(root) = value.as_object_mut() {
263        let existing = root
264            .entry("workspaces")
265            .or_insert_with(|| Value::Object(Default::default()));
266        if let Some(existing) = existing.as_object_mut() {
267            existing.extend(migrated);
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use tempfile::tempdir;
276
277    fn sample_entry(id: &str) -> ReviewEntry {
278        sample_entry_for("moz", id)
279    }
280
281    fn sample_entry_for(source: &str, id: &str) -> ReviewEntry {
282        ReviewEntry {
283            key: ReviewKey::new(source, id),
284            title: "Fix the thing".into(),
285            author: "someone".into(),
286            url: format!("https://phabricator.services.mozilla.com/{id}"),
287            repo: RepoRef {
288                urls: vec!["https://example.com/o/r".into()],
289                display_name: "o/r".into(),
290            },
291            kind: ReviewKind::Direct,
292            version: "1".into(),
293            in_queue: true,
294            resolved: false,
295            last_synced: chrono::Utc::now(),
296            stack_id: None,
297            ancestors: Vec::new(),
298            diff_stat: None,
299            description: None,
300        }
301    }
302
303    #[test]
304    fn round_trips_through_disk() {
305        let dir = tempdir().unwrap();
306        let path = dir.path().join("state.json");
307
308        let mut state = State::default();
309        state.insert(sample_entry("D1"));
310        state.save(&path).unwrap();
311
312        let loaded = State::load(&path).unwrap();
313        assert_eq!(
314            loaded.get(&ReviewKey::new("moz", "D1")).unwrap().title,
315            "Fix the thing"
316        );
317    }
318
319    #[test]
320    fn migrates_per_review_workspaces_into_the_shared_map() {
321        let dir = tempdir().unwrap();
322        let path = dir.path().join("state.json");
323        let mut entry = serde_json::to_value(sample_entry("D1")).unwrap();
324        entry.as_object_mut().unwrap().remove("stack_id");
325        entry.as_object_mut().unwrap().remove("ancestors");
326        entry.as_object_mut().unwrap().insert(
327            "workspace".into(),
328            serde_json::json!({
329                "repo_path": "/tmp/repo",
330                "vcs": "git",
331                "workspace_path": "/tmp/ws/D1",
332                "head_id": "abc123",
333                "status": "ready",
334            }),
335        );
336        let old = serde_json::json!({"reviews": {"moz/D1": entry}});
337        std::fs::write(&path, old.to_string()).unwrap();
338
339        let state = State::load(&path).unwrap();
340        let ws = state.workspace_for(&ReviewKey::new("moz", "D1")).unwrap();
341        assert_eq!(ws.head_id, "abc123");
342        assert_eq!(ws.tip, ReviewKey::new("moz", "D1"));
343        assert_eq!(ws.version, "1");
344    }
345
346    #[test]
347    fn missing_file_is_empty_state() {
348        let state = State::load(Path::new("/nonexistent/state.json")).unwrap();
349        assert_eq!(state.iter().count(), 0);
350    }
351
352    #[test]
353    fn find_by_prefix_matches_id_or_full_slug() {
354        let mut state = State::default();
355        state.insert(sample_entry("D12345"));
356        state.insert(sample_entry("D999"));
357
358        assert_eq!(state.find_by_prefix("D123").len(), 1);
359        assert_eq!(state.find_by_prefix("moz/D999").len(), 1);
360        assert_eq!(state.find_by_prefix("D").len(), 2);
361        assert_eq!(state.find_by_prefix("nope").len(), 0);
362    }
363
364    #[test]
365    fn find_by_prefix_matches_the_trailing_segment_of_a_multi_part_id() {
366        // GitHub ids are "owner/repo/number" - the part someone actually remembers (the PR
367        // number) sits at the end, not the start, so a plain `id.starts_with(prefix)` could
368        // never find it.
369        let mut state = State::default();
370        state.insert(sample_entry_for("gh", "mozilla/gecko-dev/123"));
371        state.insert(sample_entry_for("gh", "mozilla/other-repo/456"));
372
373        let by_number = state.find_by_prefix("123");
374        assert_eq!(by_number.len(), 1);
375        assert_eq!(by_number[0].key.id, "mozilla/gecko-dev/123");
376
377        // A prefix of the number should work too, not just the exact number.
378        assert_eq!(state.find_by_prefix("12").len(), 1);
379
380        // Matching still starts from a segment boundary - "23" is a suffix of "123" but not a
381        // prefix of its own segment, so it must not match.
382        assert_eq!(state.find_by_prefix("23").len(), 0);
383
384        // The existing owner/repo-starts-with behavior still works alongside the new rule.
385        assert_eq!(state.find_by_prefix("mozilla/gecko-dev").len(), 1);
386    }
387}