Skip to main content

onevcs_testing/
landings.rs

1//! Where a stack of drafts stands on this host, what a person does to it there, and
2//! what each host call is charged.
3//!
4//! The four methods a stack of review drafts asks of a host — the batched standings
5//! read with and without review feedback, a base change with its body, and a reopen —
6//! answered from the same state every other method answers from, so a standing, a
7//! `find_changes` and a `merge` cannot disagree about one change request.
8//!
9//! **The verdict is the watch's rule, stated a second time.** The classifier the crate
10//! next door applies (`publish::checks_verdict`) is private there, so it is mirrored
11//! here exactly as this crate already mirrors the watch's reading of a declaration: a
12//! declared check with no run is pending, a skipped one is never passed, a cancelled or
13//! stale one is no verdict, a failure fails, and a base requiring nothing — by the host's
14//! complete answer — requires nothing. `tests/e2e/standings.rs` next door is the drift
15//! gate: it reads the same rollups through the real classifier, the real watch and this
16//! host, and fails on any difference.
17//!
18//! **A person's moves are a journey's, never a method's.** Merging on the host, deleting
19//! the merged head, closing unmerged, retargeting: the real host takes them from people,
20//! so a journey takes them here, on [`Host`](crate::Host).
21//!
22//! **Every call is charged what GitHub charges the call the real host sends**, and
23//! written to [`HostState::ledger`]: a GraphQL query by GitHub's published rule over its
24//! connections, a mutation one point, a REST request one.
25
26use std::collections::BTreeSet;
27
28use serde::{Deserialize, Serialize};
29
30use onevcs::{
31    ChangeId, ChangeStanding, ChangeState, Check, CheckState, ChecksVerdict, Error, MergeOutcome,
32    ReadCost, Result, Sha,
33};
34
35use crate::state::HostState;
36
37/// How a person merges a change request on the host.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum PersonMerge {
41    /// One squash commit on the base.
42    Squash,
43    /// A merge commit of the head into the base.
44    MergeCommit,
45}
46
47/// What becomes of a merged change request's head branch.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum HeadAfter {
51    /// It stays on the origin.
52    Kept,
53    /// It is deleted, and every open change request based on it meets what
54    /// [`Dependants`] says.
55    Deleted(Dependants),
56}
57
58/// What deleting a merged change request's head does to the change requests based on it.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum Dependants {
62    /// They are closed — what GitHub did when `spike-quota-round` deleted a parent's
63    /// branch through the refs API.
64    Closed,
65    /// They are retargeted onto the merged change request's base.
66    Retargeted,
67}
68
69/// How many change requests one standings query reads on the real host.
70const STANDINGS_BATCH: usize = 50;
71/// How many one review-and-standings query reads on the real host.
72const REVIEW_BATCH: usize = 150;
73
74/// GitHub's published rule over `requests` connection requests: a hundredth, rounded,
75/// and never less than one point.
76fn points(requests: usize) -> u32 {
77    u32::try_from(((requests + 50) / 100).max(1)).unwrap_or(u32::MAX)
78}
79
80/// The connection requests the repository's declarations add to the first query of a
81/// call: 25 rulesets of 25 rules each, and the classic protection rules — read once per
82/// call, so each distinct base's required set is derived from them rather than read again.
83const DECLARATIONS: usize = 27;
84
85/// What the real host's standings read of `count` change requests costs: one query per
86/// 50, each one connection a change request — its rollup — and the first carrying the
87/// declarations.
88pub(crate) fn standings_charge(count: usize) -> ReadCost {
89    batched(count, STANDINGS_BATCH, 1)
90}
91
92/// What the real host's review-and-standings read of `count` change requests costs:
93/// one query per 150, each 54 connections a change request — the rollup and the review
94/// read's 53 — and the first carrying the declarations, which is 6 points over ten and
95/// 3 over five, as `spike-quota-round` measured.
96pub(crate) fn review_charge(count: usize) -> ReadCost {
97    batched(count, REVIEW_BATCH, 54)
98}
99
100fn batched(count: usize, batch: usize, each: usize) -> ReadCost {
101    ReadCost {
102        graphql_points: (0..count)
103            .step_by(batch)
104            .map(|start| {
105                let declarations = if start == 0 { DECLARATIONS } else { 0 };
106                points(each * (count - start).min(batch) + declarations)
107            })
108            .sum(),
109        rest_requests: 0,
110    }
111}
112
113/// One GraphQL query or mutation, at one point.
114pub(crate) fn graphql(points: u32) -> ReadCost {
115    ReadCost {
116        graphql_points: points,
117        rest_requests: 0,
118    }
119}
120
121/// `count` REST requests.
122pub(crate) fn rest(count: u32) -> ReadCost {
123    ReadCost {
124        graphql_points: 0,
125        rest_requests: count,
126    }
127}
128
129/// Every check the host reports on a change request's head right now: that head
130/// commit's own where a journey seeded one, and the change request's otherwise.
131pub(crate) fn checks_on_head(state: &HostState, id: &ChangeId) -> Vec<Check> {
132    let head = state
133        .changes
134        .iter()
135        .find(|change| change.id == *id)
136        .map(|change| change.head_sha.clone());
137    if let Some(checks) = head.and_then(|head| state.head_checks.get(&head)) {
138        return checks.clone();
139    }
140    crate::remote::current_checks(state, id)
141}
142
143/// What this host declares a merge into `base` requires: its seeded answer, or every
144/// check seeded as required — `None` where the base is gone and nothing can be read.
145fn declared(state: &HostState, base: &str) -> Option<BTreeSet<String>> {
146    if state.deleted_branches.contains(base) {
147        return None;
148    }
149    if let Some(seeded) = &state.required_checks {
150        return seeded
151            .complete()
152            .then(|| seeded.checks.clone())
153            .or_else(|| (!seeded.checks.is_empty()).then(|| seeded.checks.clone()));
154    }
155    Some(
156        state
157            .checks
158            .values()
159            .chain(state.checks_after_lift.values())
160            .chain(state.head_checks.values())
161            .flatten()
162            .filter(|check| check.required)
163            .map(|check| check.name.clone())
164            .collect(),
165    )
166}
167
168/// The verdict `checks` give against `declared`, by the watch's rule.
169pub(crate) fn verdict(checks: &[Check], declared: Option<&BTreeSet<String>>) -> ChecksVerdict {
170    // A declaration nobody could read is sharpened by a complete rollup that marks
171    // nothing required, as the watch sharpens it.
172    let nothing = match declared {
173        Some(names) => names.is_empty(),
174        None => !checks.is_empty() && checks.iter().all(|check| !check.required),
175    };
176    let names: BTreeSet<String> = match declared {
177        Some(names) => names.clone(),
178        None if nothing => BTreeSet::new(),
179        None => checks
180            .iter()
181            .filter(|check| check.required)
182            .map(|check| check.name.clone())
183            .collect(),
184    };
185    let rank = |state: CheckState| match state {
186        CheckState::Failed => 0,
187        CheckState::Pending => 1,
188        CheckState::Passed => 2,
189        CheckState::NoVerdict => 3,
190        CheckState::Skipped => 4,
191    };
192    let standing: Vec<(String, Option<CheckState>)> = names
193        .into_iter()
194        .map(|name| {
195            let state = checks
196                .iter()
197                .filter(|check| check.name == name)
198                .map(Check::state)
199                .min_by_key(|state| rank(*state));
200            (name, state)
201        })
202        .collect();
203    let named = |wanted: &dyn Fn(Option<CheckState>) -> bool| -> Vec<String> {
204        standing
205            .iter()
206            .filter(|(_, state)| wanted(*state))
207            .map(|(name, _)| name.clone())
208            .collect()
209    };
210    let failed = named(&|state| state == Some(CheckState::Failed));
211    if !failed.is_empty() {
212        return ChecksVerdict::Failed { failed };
213    }
214    if nothing && standing.is_empty() {
215        return ChecksVerdict::NoneRequired;
216    }
217    let through = (nothing || !standing.is_empty())
218        && standing
219            .iter()
220            .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)));
221    if through {
222        let skipped = named(&|state| state == Some(CheckState::Skipped));
223        return if skipped.is_empty() {
224            ChecksVerdict::Passed
225        } else {
226            ChecksVerdict::PassedWithSkipped { skipped }
227        };
228    }
229    ChecksVerdict::Pending {
230        waiting: named(&|state| {
231            matches!(
232                state,
233                None | Some(CheckState::Pending | CheckState::NoVerdict)
234            )
235        }),
236    }
237}
238
239/// Where one change request stands on this host.
240pub(crate) fn standing(state: &HostState, root: &str, id: &ChangeId) -> Result<ChangeStanding> {
241    let change = state
242        .changes
243        .iter()
244        .find(|change| change.id == *id)
245        .cloned()
246        .ok_or_else(|| Error::Invalid {
247            reason: format!(
248                "no change request {:?} was opened on this host, so it has no standing",
249                id.0
250            ),
251        })?;
252    let base = change.base.clone();
253    let state_now = match state.merges.get(id) {
254        Some(MergeOutcome::Merged(commit)) => ChangeState::Merged {
255            into: base.clone(),
256            commit: commit.clone(),
257            landing: base == root,
258            merged_at: state.merge_times.get(id).cloned(),
259        },
260        _ if state.closed.contains(id) => ChangeState::Closed {
261            base_deleted: state.deleted_branches.contains(&base),
262        },
263        _ => ChangeState::Open,
264    };
265    let checks = checks_on_head(state, id);
266    Ok(ChangeStanding {
267        checks: verdict(&checks, declared(state, &base).as_ref()),
268        body: state.bodies.get(id).cloned().unwrap_or_default(),
269        head_branch: state.heads.get(id).cloned().unwrap_or_default(),
270        head: change.head_sha.clone(),
271        draft: crate::remote::drafted(state, id),
272        url: change.url,
273        base,
274        state: state_now,
275    })
276}
277
278/// A person merges change request `id` on this host into the base it targets now,
279/// at `at`, and keeps or deletes its head. Answers the commit the base reached.
280pub(crate) fn merge_by_person(
281    state: &mut HostState,
282    id: &ChangeId,
283    how: PersonMerge,
284    at: &str,
285    head: HeadAfter,
286) -> Result<Sha> {
287    let change = state
288        .changes
289        .iter()
290        .find(|change| change.id == *id)
291        .cloned()
292        .ok_or_else(|| Error::Invalid {
293            reason: format!("no change request {:?} was opened on this host", id.0),
294        })?;
295    if crate::events::moment(at).is_none() {
296        return Err(Error::Invalid {
297            reason: format!("{at:?} is not an RFC 3339 moment to merge at"),
298        });
299    }
300    if matches!(state.merges.get(id), Some(MergeOutcome::Merged(_))) || state.closed.contains(id) {
301        return Err(Error::Invalid {
302            reason: format!("{} is not open, so nobody can merge it", change.url),
303        });
304    }
305    let word = match how {
306        PersonMerge::Squash => "squash",
307        PersonMerge::MergeCommit => "merge-commit",
308    };
309    let sha = Sha(crate::events::stable_sha(&[
310        "person-merge",
311        word,
312        &id.0,
313        change.url.as_str(),
314    ]));
315    state
316        .merges
317        .insert(id.clone(), MergeOutcome::Merged(sha.clone()));
318    state.merge_times.insert(id.clone(), at.to_owned());
319    if let HeadAfter::Deleted(dependants) = head {
320        let branch = state.heads.get(id).cloned().unwrap_or_default();
321        state.deleted_branches.insert(branch.clone());
322        let based: Vec<ChangeId> = state
323            .changes
324            .iter()
325            .filter(|other| {
326                other.base == branch
327                    && !state.closed.contains(&other.id)
328                    && !matches!(state.merges.get(&other.id), Some(MergeOutcome::Merged(_)))
329            })
330            .map(|other| other.id.clone())
331            .collect();
332        for other in based {
333            match dependants {
334                Dependants::Closed => {
335                    state.closed.insert(other);
336                }
337                Dependants::Retargeted => retarget_in(state, &other, &change.base),
338            }
339        }
340    }
341    Ok(sha)
342}
343
344/// Move one change request's base in the state, as the host records it.
345pub(crate) fn retarget_in(state: &mut HostState, id: &ChangeId, base: &str) {
346    if let Some(change) = state.changes.iter_mut().find(|change| change.id == *id) {
347        change.base = base.to_owned();
348    }
349}