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            .chain(
162                state
163                    .check_readings
164                    .values()
165                    .chain(state.check_readings_after_lift.values())
166                    .flatten(),
167            )
168            .flatten()
169            .filter(|check| check.required)
170            .map(|check| check.name.clone())
171            .collect(),
172    )
173}
174
175/// The verdict `checks` give against `declared`, by the watch's rule.
176pub(crate) fn verdict(checks: &[Check], declared: Option<&BTreeSet<String>>) -> ChecksVerdict {
177    // A declaration nobody could read is sharpened by a complete rollup that marks
178    // nothing required, as the watch sharpens it.
179    let nothing = match declared {
180        Some(names) => names.is_empty(),
181        None => !checks.is_empty() && checks.iter().all(|check| !check.required),
182    };
183    let names: BTreeSet<String> = match declared {
184        Some(names) => names.clone(),
185        None if nothing => BTreeSet::new(),
186        None => checks
187            .iter()
188            .filter(|check| check.required)
189            .map(|check| check.name.clone())
190            .collect(),
191    };
192    let rank = |state: CheckState| match state {
193        CheckState::Failed => 0,
194        CheckState::Pending => 1,
195        CheckState::Passed => 2,
196        CheckState::NoVerdict => 3,
197        CheckState::Skipped => 4,
198    };
199    let standing: Vec<(String, Option<CheckState>)> = names
200        .into_iter()
201        .map(|name| {
202            let state = checks
203                .iter()
204                .filter(|check| check.name == name)
205                .map(Check::state)
206                .min_by_key(|state| rank(*state));
207            (name, state)
208        })
209        .collect();
210    let named = |wanted: &dyn Fn(Option<CheckState>) -> bool| -> Vec<String> {
211        standing
212            .iter()
213            .filter(|(_, state)| wanted(*state))
214            .map(|(name, _)| name.clone())
215            .collect()
216    };
217    let failed = named(&|state| state == Some(CheckState::Failed));
218    if !failed.is_empty() {
219        return ChecksVerdict::Failed { failed };
220    }
221    if nothing && standing.is_empty() {
222        return ChecksVerdict::NoneRequired;
223    }
224    let through = (nothing || !standing.is_empty())
225        && standing
226            .iter()
227            .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)));
228    if through {
229        let skipped = named(&|state| state == Some(CheckState::Skipped));
230        return if skipped.is_empty() {
231            ChecksVerdict::Passed
232        } else {
233            ChecksVerdict::PassedWithSkipped { skipped }
234        };
235    }
236    ChecksVerdict::Pending {
237        waiting: named(&|state| {
238            matches!(
239                state,
240                None | Some(CheckState::Pending | CheckState::NoVerdict)
241            )
242        }),
243    }
244}
245
246/// Where one change request stands on this host.
247pub(crate) fn standing(state: &HostState, root: &str, id: &ChangeId) -> Result<ChangeStanding> {
248    let change = state
249        .changes
250        .iter()
251        .find(|change| change.id == *id)
252        .cloned()
253        .ok_or_else(|| Error::Invalid {
254            reason: format!(
255                "no change request {:?} was opened on this host, so it has no standing",
256                id.0
257            ),
258        })?;
259    let base = change.base.clone();
260    let state_now = match state.merges.get(id) {
261        Some(MergeOutcome::Merged(commit)) => ChangeState::Merged {
262            into: base.clone(),
263            commit: commit.clone(),
264            landing: base == root,
265            merged_at: state.merge_times.get(id).cloned(),
266        },
267        _ if state.closed.contains(id) => ChangeState::Closed {
268            base_deleted: state.deleted_branches.contains(&base),
269        },
270        _ => ChangeState::Open,
271    };
272    let checks = checks_on_head(state, id);
273    Ok(ChangeStanding {
274        checks: verdict(&checks, declared(state, &base).as_ref()),
275        body: state.bodies.get(id).cloned().unwrap_or_default(),
276        head_branch: state.heads.get(id).cloned().unwrap_or_default(),
277        head: change.head_sha.clone(),
278        draft: crate::remote::drafted(state, id),
279        url: change.url,
280        base,
281        state: state_now,
282    })
283}
284
285/// A person merges change request `id` on this host into the base it targets now,
286/// at `at`, and keeps or deletes its head. Answers the commit the base reached.
287pub(crate) fn merge_by_person(
288    state: &mut HostState,
289    id: &ChangeId,
290    how: PersonMerge,
291    at: &str,
292    head: HeadAfter,
293) -> Result<Sha> {
294    let change = state
295        .changes
296        .iter()
297        .find(|change| change.id == *id)
298        .cloned()
299        .ok_or_else(|| Error::Invalid {
300            reason: format!("no change request {:?} was opened on this host", id.0),
301        })?;
302    if crate::events::moment(at).is_none() {
303        return Err(Error::Invalid {
304            reason: format!("{at:?} is not an RFC 3339 moment to merge at"),
305        });
306    }
307    if matches!(state.merges.get(id), Some(MergeOutcome::Merged(_))) || state.closed.contains(id) {
308        return Err(Error::Invalid {
309            reason: format!("{} is not open, so nobody can merge it", change.url),
310        });
311    }
312    let word = match how {
313        PersonMerge::Squash => "squash",
314        PersonMerge::MergeCommit => "merge-commit",
315    };
316    let sha = Sha(crate::events::stable_sha(&[
317        "person-merge",
318        word,
319        &id.0,
320        change.url.as_str(),
321    ]));
322    state
323        .merges
324        .insert(id.clone(), MergeOutcome::Merged(sha.clone()));
325    state.merge_times.insert(id.clone(), at.to_owned());
326    if let HeadAfter::Deleted(dependants) = head {
327        let branch = state.heads.get(id).cloned().unwrap_or_default();
328        state.deleted_branches.insert(branch.clone());
329        let based: Vec<ChangeId> = state
330            .changes
331            .iter()
332            .filter(|other| {
333                other.base == branch
334                    && !state.closed.contains(&other.id)
335                    && !matches!(state.merges.get(&other.id), Some(MergeOutcome::Merged(_)))
336            })
337            .map(|other| other.id.clone())
338            .collect();
339        for other in based {
340            match dependants {
341                Dependants::Closed => {
342                    state.closed.insert(other);
343                }
344                Dependants::Retargeted => retarget_in(state, &other, &change.base),
345            }
346        }
347    }
348    Ok(sha)
349}
350
351/// Move one change request's base in the state, as the host records it.
352pub(crate) fn retarget_in(state: &mut HostState, id: &ChangeId, base: &str) {
353    if let Some(change) = state.changes.iter_mut().find(|change| change.id == *id) {
354        change.base = base.to_owned();
355    }
356}