OXYGENHUB
# dc59d1b56c3d oxygenhub / crates / hub / src / repos / proposals.rs
8.8 KiB · Rust · last changed Milestone 0.3: proposals and review 1 month ago source blame history raw
1
//! Proposals (hub spec §7): the projected state, its diff and conflicts,
2
//! atom anchors for comments, seal-backed approvals, code owners and the
3
//! landing requirements. Pure functions over `oxy-core`, run under the
4
//! repository lock.
5
6
use super::cache::{Loaded, StateCache};
7
use super::view::{self, ChangeView, ConflictView, RefTarget};
8
use super::RepoError;
9
use oxy_core::hash::{ChangeHash, KeyId, ObjectRef};
10
use oxy_core::objects::StateObject;
11
use oxy_core::repo::{FileDiff, Repo};
12
use oxy_core::state::FileContent;
13
use std::collections::{BTreeMap, BTreeSet, HashMap};
14
use std::sync::Arc;
15
16
type Result<T> = std::result::Result<T, RepoError>;
17
18
/// Where a proposal's changes come from, resolved.
19
pub enum Source<'a> {
20
    /// Heads of a bookmark of this repository.
21
    Bookmark(Vec<ChangeHash>),
22
    /// A fork: its repository directory and the bookmark's heads there.
23
    Fork {
24
        fork: &'a Repo,
25
        heads: Vec<ChangeHash>,
26
    },
27
    Changes(Vec<ChangeHash>),
28
}
29
30
/// The projected state of a proposal and what the pages show of it.
31
pub struct Projection {
32
    pub target_heads: Vec<ChangeHash>,
33
    pub target_state: ObjectRef,
34
    pub heads: Vec<ChangeHash>,
35
    pub state: ObjectRef,
36
    /// The changes the proposal adds, dependency order.
37
    pub changes: Vec<ChangeView>,
38
    pub hashes: Vec<ChangeHash>,
39
    pub conflicted: bool,
40
    pub conflicts: Vec<ConflictView>,
41
    pub files: Vec<FileDiff>,
42
    pub bases: BTreeMap<String, Vec<u8>>,
43
    pub loaded: Arc<Loaded>,
44
}
45
46
/// Compute the projection: the union of the target's heads and the
47
/// source, with the source's changes brought into this store when they
48
/// come from a fork.
49
pub fn project(repo: &Repo, cache: &StateCache, target: &str, source: Source<'_>) -> Result<Projection> {
50
    let target_heads = repo.bookmarks()?.into_iter().find(|b| b.name == target).map(|b| b.heads).unwrap_or_default();
51
    let candidate: Vec<ChangeHash> = match &source {
52
        Source::Bookmark(heads) => heads.clone(),
53
        Source::Changes(hashes) => hashes.clone(),
54
        Source::Fork { fork, heads } => {
55
            // Objects are content addressed: copy what is missing straight from the fork's store.
56
            let fork_state = fork.load_state(heads)?;
57
            let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !repo.store().has(h)).collect();
58
            if !missing.is_empty() {
59
                let bundle = fork.bundle_for(&missing)?;
60
                repo.import_bundle(&bundle, repo.current_policy()?.is_some())?;
61
            }
62
            heads.clone()
63
        }
64
    };
65
    for h in &candidate {
66
        if !repo.store().has(h) {
67
            return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("change {} is not in this repository", h.short(12)))));
68
        }
69
    }
70
    let mut union: Vec<ChangeHash> = target_heads.clone();
71
    union.extend(candidate.iter().copied());
72
    let loaded = cache.load(repo, &union)?;
73
    let heads = loaded.state.heads();
74
    let target_loaded = cache.load(repo, &target_heads)?;
75
    let labels = view::head_labels(repo)?;
76
    let head_set: BTreeSet<ChangeHash> = heads.iter().copied().collect();
77
    let mut added: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> =
78
        loaded.state.changes().filter(|(h, _)| !target_loaded.state.contains(h)).map(|(h, m)| (*h, m.clone())).collect();
79
    // Dependency order, oldest first: a change after everything it depends on.
80
    added.sort_by(|a, b| a.1.hlc.cmp(&b.1.hlc).then(a.0.cmp(&b.0)));
81
    let hashes: Vec<ChangeHash> = added.iter().map(|(h, _)| *h).collect();
82
    let changes: Vec<ChangeView> = added.iter().map(|(h, m)| view::change_view_pub(repo, h, m, head_set.contains(h), &labels)).collect();
83
    let conflicted = loaded.snapshot.is_conflicted();
84
    let conflicts = if conflicted { view::conflicts_in(repo, &loaded, "projected") } else { vec![] };
85
    let (files, bases) = crate::render::diff::snapshot_diff(&target_loaded.snapshot, &loaded.snapshot);
86
    let state = StateObject::new(heads.clone()).hash();
87
    let target_state = StateObject::new(target_heads.clone()).hash();
88
    Ok(Projection { target_heads, target_state, heads, state, changes, hashes, conflicted, conflicts, files, bases, loaded })
89
}
90
91
/// The atom at line `new_no` (1-based) of `path` in the projected state.
92
pub fn atom_at(loaded: &Loaded, path: &str, new_no: usize) -> Option<(ChangeHash, u32)> {
93
    let f = loaded.snapshot.files.get(path)?;
94
    if !matches!(f.content, FileContent::Text { .. }) {
95
        return None;
96
    }
97
    let lines = loaded.state.live_lines(&f.node);
98
    lines.get(new_no.checked_sub(1)?).map(|((h, i), _)| (*h, *i))
99
}
100
101
/// The current line (1-based) of an atom in `path`, if it is still live.
102
pub fn line_of(loaded: &Loaded, path: &str, atom: (ChangeHash, u32)) -> Option<usize> {
103
    let f = loaded.snapshot.files.get(path)?;
104
    let lines = loaded.state.live_lines(&f.node);
105
    lines.iter().position(|((h, i), _)| (*h, *i) == atom).map(|p| p + 1)
106
}
107
108
/// Keys that have sealed every one of `hashes` with a valid seal.
109
pub fn sealing_keys(repo: &Repo, hashes: &[ChangeHash]) -> Result<Vec<KeyId>> {
110
    if hashes.is_empty() {
111
        return Ok(vec![]);
112
    }
113
    let mut counts: HashMap<KeyId, usize> = HashMap::new();
114
    for h in hashes {
115
        let mut keys_here: BTreeSet<KeyId> = BTreeSet::new();
116
        for seal_ref in repo.store().seals_for(h)? {
117
            let attest = repo.store().get_attest(&seal_ref)?;
118
            let oxy_core::attest::AttestBody::Seal { hashes: covered } = &attest.body else { continue };
119
            if !covered.contains(h) {
120
                continue;
121
            }
122
            for sig in repo.store().signatures(&seal_ref)? {
123
                if let Ok(id) = repo.store().get_identity(&sig.key) {
124
                    if oxy_core::identity::verify(&id, &sig, oxy_core::identity::Domain::Attest, &seal_ref).is_ok() {
125
                        keys_here.insert(sig.key);
126
                    }
127
                }
128
            }
129
        }
130
        for k in keys_here {
131
            *counts.entry(k).or_default() += 1;
132
        }
133
    }
134
    Ok(counts.into_iter().filter(|(_, n)| *n == hashes.len()).map(|(k, _)| k).collect())
135
}
136
137
/// `.oxyowners` in the target state (CODEOWNERS syntax): the owners of
138
/// every path the proposal touches.
139
pub fn owners_for(target: &Loaded, touched: &[String]) -> Vec<String> {
140
    let Some(f) = target.snapshot.files.get(".oxyowners") else { return vec![] };
141
    let FileContent::Text { bytes, .. } = &f.content else { return vec![] };
142
    let text = String::from_utf8_lossy(bytes);
143
    let rules: Vec<(String, Vec<String>)> = text
144
        .lines()
145
        .map(str::trim)
146
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
147
        .filter_map(|l| {
148
            let mut parts = l.split_whitespace();
149
            let pattern = parts.next()?.to_string();
150
            let owners: Vec<String> = parts.map(|o| o.trim_start_matches('@').to_string()).collect();
151
            Some((pattern, owners))
152
        })
153
        .collect();
154
    let mut out: BTreeSet<String> = BTreeSet::new();
155
    for path in touched {
156
        // The last matching rule wins, as in CODEOWNERS.
157
        if let Some((_, owners)) = rules.iter().rev().find(|(pattern, _)| owner_pattern_matches(pattern, path)) {
158
            out.extend(owners.iter().cloned());
159
        }
160
    }
161
    out.into_iter().collect()
162
}
163
164
fn owner_pattern_matches(pattern: &str, path: &str) -> bool {
165
    let p = pattern.trim_start_matches('/');
166
    if p == "*" {
167
        return true;
168
    }
169
    if let Some(dir) = p.strip_suffix('/') {
170
        return path.starts_with(&format!("{dir}/"));
171
    }
172
    if let Some(ext) = p.strip_prefix("*.") {
173
        return path.ends_with(&format!(".{ext}"));
174
    }
175
    if let Some(prefix) = p.strip_suffix("/*") {
176
        return path.strip_prefix(&format!("{prefix}/")).is_some_and(|rest| !rest.contains('/'));
177
    }
178
    if let Some(prefix) = p.strip_suffix("/**") {
179
        return path.starts_with(&format!("{prefix}/"));
180
    }
181
    path == p || path.starts_with(&format!("{p}/"))
182
}
183
184
/// The union of two head lists as a state object reference, for landing.
185
pub fn state_ref(heads: &[ChangeHash]) -> ObjectRef {
186
    StateObject::new(heads.to_vec()).hash()
187
}
188
189
/// A proposal's changes as a `RefTarget`, for browsing its tree.
190
pub fn target_of(label: &str, heads: &[ChangeHash]) -> RefTarget {
191
    RefTarget { label: label.into(), kind: view::RefKind::Change, heads: heads.to_vec() }
192
}
193
194
#[cfg(test)]
195
mod tests {
196
    use super::*;
197
198
    #[test]
199
    fn owner_patterns() {
200
        assert!(owner_pattern_matches("*", "any/thing"));
201
        assert!(owner_pattern_matches("docs/", "docs/spec.md"));
202
        assert!(!owner_pattern_matches("docs/", "src/docs.rs"));
203
        assert!(owner_pattern_matches("*.rs", "src/main.rs"));
204
        assert!(owner_pattern_matches("src/*", "src/main.rs"));
205
        assert!(!owner_pattern_matches("src/*", "src/a/b.rs"));
206
        assert!(owner_pattern_matches("crates/**", "crates/hub/src/lib.rs"));
207
        assert!(owner_pattern_matches("Cargo.toml", "Cargo.toml"));
208
    }
209
}