Skip to main content

onevcs/
standings.rs

1//! Where a repository's change requests stand, read in one batch; a change request
2//! moved onto another base; and a draft the host closed under a deleted base, reopened.
3//!
4//! What a stack of review drafts needs while people merge some of them on the host and
5//! the rest are still in review. Four operations over the host, each re-exported from
6//! the crate root:
7//!
8//! - [`change_standings`] reads every named change request of one repository — its
9//!   state, the branch it merged into, its base, head, description, draft flag and its
10//!   required checks' verdict — in **one GraphQL query per 50**, and records a merge
11//!   into the root as the landing it is, whoever merged it.
12//! - [`review_standings`] is the same read carrying each change request's review
13//!   threads, comments and reviews, in one aliased query per 150 of them, each change's
14//!   [`ReviewRead`] computed exactly as [`review_comments`](crate::review_comments)
15//!   computes it from the same marker.
16//! - [`retarget_change`] moves a change request to another base, writing its
17//!   description in the same request where one is given, and never changes whether it
18//!   is a draft.
19//! - [`reopen_change`] restores the base branch a merged parent's deletion took away —
20//!   which closed the drafts based on it rather than retargeting them — and reopens
21//!   the change request as the draft it was.
22//!
23//! **The verdict is the watch's.** A standing's [`ChecksVerdict`] is decided by the
24//! classifier a publication's check watch applies to the same rollup
25//! (`publish::checks_verdict`), so a standings read and a watch cannot disagree about
26//! one head.
27//!
28//! **A landing found here is recorded the way a late merge is.** A change request the
29//! host reports merged into the root is written as a landing through
30//! `release::reconcile_landing` — the publication checkout fast-forwarded, the release
31//! baselines captured under the existing rules, and a `change-merged` record carrying
32//! the host's merge time — whether or not this host asked the host to land it and
33//! whether or not any session here holds its branch, so `landing_status` then answers
34//! `yes` by record. A landing already recorded at the same commit is not written again.
35//!
36//! **What a read saw of each head is kept.** Every standing's head branch is written
37//! under `$ONEVCS_HOME/change-heads/`, beside the `change-opened` records a publication
38//! leaves, because a publication that defers its checks may make no host call at all
39//! and has to know from this host's own records which branch a change request carries.
40
41use std::collections::BTreeMap;
42use std::path::PathBuf;
43
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46use url::Url;
47
48use crate::boundary::{Surface, TermScope};
49use crate::error::{invalid, Error, Result};
50use crate::event::EventKind;
51use crate::host::{ChangeId, RemoteHost, Sha};
52use crate::providers::Providers;
53use crate::review::{ReadCost, ReadMarker, ReviewRead};
54use crate::stream::Stream;
55use crate::workspace::{object, Ref};
56use crate::{git, home, ids, publish, release, review, status, store};
57
58pub(crate) mod github;
59
60/// How many change requests one standings query reads: GitHub's published cost rule
61/// charges a query whose connections ask for under 150 nodes one point, and 50 rollups
62/// with the repository's declarations beside them are 77.
63pub(crate) const STANDINGS_BATCH: usize = 50;
64
65/// How many change requests one review-and-standings query reads: the most
66/// `spike-review-loop` ran in one query, comfortably inside GitHub's limit on the
67/// nodes one query may ask for.
68pub(crate) const REVIEW_BATCH: usize = 150;
69
70/// Which change requests to read, and the branch a merge into which is a landing.
71// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields as
72// written; `change_standings` resolves `repo` through the registry, holds `root` to
73// git's branch grammar and every URL to the repository `repo` names, before the host is
74// asked anything.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ChangeStandingsRequest {
77    /// The identity (normalized origin) or a registered checkout path.
78    pub repo: String,
79    /// The root base: a change request merged into it is a landing.
80    pub root: String,
81    /// The change requests to read, by URL, all of that repository; any number.
82    pub changes: Vec<Url>,
83}
84
85/// Where one change request stands, as the host reports it.
86// llmlint: ignore[invalid_states_unrepresentable] every text field is the host's own,
87// read out of its answer: the contract declares them `String`, and the one combination
88// a host could contradict itself with — a merge naming no commit — is refused where the
89// answer is read.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ChangeStanding {
92    /// The change request, as the host addresses it.
93    pub url: Url,
94    /// The description as the host holds it, so a caller can rewrite one part of it.
95    pub body: String,
96    /// The branch the change request targets now, as the host reports it.
97    pub base: String,
98    /// The branch it carries.
99    pub head_branch: String,
100    /// The commit its head is at.
101    pub head: Sha,
102    /// Whether the host holds it as a draft.
103    pub draft: bool,
104    /// Open, merged and into which branch, or closed.
105    pub state: ChangeState,
106    /// The required checks' verdict on `head`.
107    pub checks: ChecksVerdict,
108}
109
110/// Whether a change request is open, merged, or closed without merging.
111#[non_exhaustive]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "state", rename_all = "kebab-case")]
114pub enum ChangeState {
115    /// Open, for review or as a draft.
116    Open,
117    /// Merged. `landing` is true exactly when `into` is the request's `root`.
118    Merged {
119        /// The branch it merged into.
120        into: String,
121        /// The commit it reached that branch as.
122        commit: Sha,
123        /// Whether `into` is the root, which makes the merge a landing.
124        landing: bool,
125        /// When the host says it merged, RFC 3339, or `None` where the host gave none.
126        // llmlint: ignore[invalid_states_unrepresentable] the host's own moment, carried as
127        // it spelled it — the manager's ruling on this amendment — and read through
128        // `Moment::reported` wherever a landing is recorded with it.
129        #[serde(default, skip_serializing_if = "Option::is_none")]
130        merged_at: Option<String>,
131    },
132    /// Closed without merging. `base_deleted` is true when the branch it targets no
133    /// longer exists on the origin.
134    Closed {
135        /// Whether the branch it targets is gone from the origin — which is how a
136        /// draft whose parent's branch was deleted ends up closed.
137        base_deleted: bool,
138    },
139}
140
141/// What the required checks on a change request's head say, by the rules a
142/// publication's watch reads them by.
143#[non_exhaustive]
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "verdict", rename_all = "kebab-case")]
146pub enum ChecksVerdict {
147    /// Something required has not settled: no run yet, still running, or ended with no
148    /// verdict (cancelled, stale).
149    Pending {
150        /// The required checks still awaited, by name.
151        waiting: Vec<String>,
152    },
153    /// Every required check passed.
154    Passed,
155    /// Every required check passed or was skipped, and these were skipped — which is
156    /// never read as passed.
157    PassedWithSkipped {
158        /// The required checks that concluded skipped.
159        skipped: Vec<String>,
160    },
161    /// A required check failed.
162    Failed {
163        /// The required checks that failed.
164        failed: Vec<String>,
165    },
166    /// The base requires nothing, by the host's complete answer.
167    NoneRequired,
168}
169
170/// Where each change request stands, and what the read cost.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ChangeStandings {
173    /// One standing per change request asked about, in the order asked.
174    pub standings: Vec<ChangeStanding>,
175    /// What the host charged for the read.
176    pub cost: ReadCost,
177}
178
179/// Read where every named change request of one repository stands.
180///
181/// The library form of `onevcs change standings`. One GraphQL query per 50 change
182/// requests and no REST request; a merge into `root` is recorded as a landing where
183/// it is not already. Records one `change-standings-read` event.
184pub fn change_standings(
185    providers: &Providers<'_>,
186    request: &ChangeStandingsRequest,
187) -> Result<ChangeStandings> {
188    let repository = Repository::of(providers, &request.repo)?;
189    let root = branch_name(&request.root, "the root")?;
190    let ids = repository.ids(request.changes.iter())?;
191    let read = if ids.is_empty() {
192        ChangeStandings {
193            standings: Vec::new(),
194            cost: ReadCost::default(),
195        }
196    } else {
197        repository.host.change_standings(&root, &ids)?
198    };
199    repository.answered(&ids, &read.standings)?;
200    let landed = repository.take_up(&read.standings, &root);
201    repository.record_read(read.standings.len(), &landed, read.cost);
202    Ok(read)
203}
204
205/// One change request a review-and-standings read is asked about, and the marker an
206/// earlier read of its review feedback answered.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct ReviewTarget {
209    /// The change request, by URL.
210    pub change: Url,
211    /// What to read its review feedback since; `None` reads all of it.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub since: Option<ReadMarker>,
214}
215
216/// Which change requests to read with their review feedback, and the root.
217// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields;
218// they are held where `change_standings`'s are, before the host is asked anything.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct ReviewStandingsRequest {
221    /// The identity (normalized origin) or a registered checkout path.
222    pub repo: String,
223    /// The root base: a change request merged into it is a landing.
224    pub root: String,
225    /// The change requests to read, each with its marker.
226    pub changes: Vec<ReviewTarget>,
227}
228
229/// Where each change request stands, what its review feedback says, and the cost.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ReviewStandings {
232    /// One standing per change request asked about, in the order asked.
233    pub standings: Vec<ChangeStanding>,
234    /// One review read per change request, in the same order.
235    pub reads: Vec<ReviewRead>,
236    /// What the host charged for the whole read.
237    pub cost: ReadCost,
238}
239
240/// Read where every named change request stands **and** its review feedback, in one
241/// aliased query per 150 change requests.
242///
243/// The library form of `onevcs change standings --review`. Each change's
244/// [`ReviewRead`] is the one [`review_comments`](crate::review_comments) computes from
245/// the same marker; a merge into `root` is recorded as a landing. Records one
246/// `review-comments-read` event per change request and one `change-standings-read`.
247pub fn review_standings(
248    providers: &Providers<'_>,
249    request: &ReviewStandingsRequest,
250) -> Result<ReviewStandings> {
251    let repository = Repository::of(providers, &request.repo)?;
252    let root = branch_name(&request.root, "the root")?;
253    let ids = repository.ids(request.changes.iter().map(|target| &target.change))?;
254    let targets: Vec<(ChangeId, Option<ReadMarker>)> = ids
255        .iter()
256        .cloned()
257        .zip(request.changes.iter().map(|target| target.since.clone()))
258        .collect();
259    let read = if targets.is_empty() {
260        ReviewStandings {
261            standings: Vec::new(),
262            reads: Vec::new(),
263            cost: ReadCost::default(),
264        }
265    } else {
266        repository.host.review_standings(&root, &targets)?
267    };
268    repository.answered(&ids, &read.standings)?;
269    if read.reads.len() != ids.len() {
270        return Err(invalid(format!(
271            "the host answered {} review read(s) for {} change request(s), so which feedback \
272             belongs to which cannot be read",
273            read.reads.len(),
274            ids.len()
275        )));
276    }
277    let landed = repository.take_up(&read.standings, &root);
278    for review_read in &read.reads {
279        review::record_url_read(&repository.key, review_read);
280    }
281    repository.record_read(read.standings.len(), &landed, read.cost);
282    Ok(read)
283}
284
285/// A change request to move onto another base.
286// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields;
287// `retarget_change` holds `base` to git's grammar, the URL to `repo`, and refuses
288// `remove_restored_base` for a base this host did not restore, before anything is
289// written.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct RetargetRequest {
292    /// The identity (normalized origin) or a registered checkout path.
293    pub repo: String,
294    /// The change request, by URL.
295    pub change: Url,
296    /// The base it should target.
297    pub base: String,
298    /// A description written in the same request as the base change.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub body: Option<String>,
301    /// Delete the previous base branch after the move — only one `reopen_change` restored.
302    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
303    pub remove_restored_base: bool,
304}
305
306/// What moving a change request did.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct Retargeted {
309    /// The change request, as the host addresses it.
310    pub change: Url,
311    /// The base it targeted before.
312    pub from: String,
313    /// The base it targets now.
314    pub to: String,
315    /// Whether its base moved.
316    pub outcome: RetargetOutcome,
317    /// What the host charged for the call, reads and the write together.
318    pub cost: ReadCost,
319}
320
321/// Whether a retarget moved the base.
322#[non_exhaustive]
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "kebab-case")]
325pub enum RetargetOutcome {
326    /// The base moved (and the body, where one was given, was written with it).
327    Retargeted,
328    /// It already targeted the base: nothing moved, and only a given body was written.
329    Unchanged,
330}
331
332/// Move a change request onto `base`, writing its description in the same request.
333///
334/// The library form of `onevcs change retarget`. Refused, with nothing written, for a
335/// merged or closed change request, a base the origin does not hold, and
336/// `remove_restored_base` naming a base [`reopen_change`] did not restore on this
337/// change request; the body is held to the public boundary before it is written. The
338/// draft state never changes, and a write the host answered with it changed is an
339/// error. Records one `change-retargeted` event.
340pub fn retarget_change(providers: &Providers<'_>, request: &RetargetRequest) -> Result<Retargeted> {
341    let repository = Repository::of(providers, &request.repo)?;
342    let base = branch_name(&request.base, "the base")?;
343    let id = repository.id(&request.change)?;
344    let mut cost = ReadCost::default();
345    let before = repository.one(&id, &base, &mut cost)?;
346    let url = before.url.clone();
347    match &before.state {
348        ChangeState::Open => {}
349        ChangeState::Merged { into, .. } => {
350            return Err(invalid(format!(
351                "{url} has merged into {into:?}, so its base cannot change. Nothing was written"
352            )))
353        }
354        _ => {
355            return Err(invalid(format!(
356                "{url} is closed, and a closed change request's base cannot change: reopen it \
357                 first — `onevcs change reopen` restores a deleted base and reopens it. Nothing \
358                 was written"
359            )))
360        }
361    }
362    let from = before.base.clone();
363    let moves = from != base;
364    if request.remove_restored_base {
365        if !moves {
366            return Err(invalid(format!(
367                "{url} already targets {base:?}, so no base is left behind for \
368                 --remove-restored-base to delete. Nothing was written"
369            )));
370        }
371        if !restored_on(&url, &from)? {
372            return Err(invalid(format!(
373                "{from:?} is not a base `onevcs change reopen` restored on {url}, so \
374                 --remove-restored-base will not delete it. Nothing was written"
375            )));
376        }
377    }
378    if !moves && request.body.is_none() {
379        repository.record_change(
380            &url,
381            EventKind::ChangeRetargeted,
382            json!({
383                "change_url": url.to_string(),
384                "from": from,
385                "to": base,
386                "outcome": "unchanged",
387                "body_written": false,
388            }),
389        );
390        return Ok(Retargeted {
391            change: url,
392            to: base,
393            from,
394            outcome: RetargetOutcome::Unchanged,
395            cost,
396        });
397    }
398    let tips = repository.origin_branches()?;
399    if moves && !tips.contains_key(&base) {
400        return Err(invalid(format!(
401            "the origin of {} holds no branch {base:?}, so {url} cannot target it. Nothing was \
402             written",
403            repository.key
404        )));
405    }
406    // Every write is held to the public boundary before the host is asked to make it,
407    // a base-only move included: the base it names is written to the change request.
408    let mut fields = vec![(Surface::Branch, base.as_str())];
409    if let Some(body) = &request.body {
410        fields.push((Surface::Body, body.as_str()));
411    }
412    crate::boundary::evidence::guard_fields(
413        providers.hosting,
414        &repository.key,
415        &fields,
416        &TermScope::default(),
417    )?;
418    add(
419        &mut cost,
420        repository
421            .host
422            .retarget_change(&id, &base, request.body.as_deref())?,
423    );
424    let after = repository.one(&id, &base, &mut cost)?;
425    if after.draft != before.draft {
426        return Err(invalid(format!(
427            "the host moved {url} onto {base:?} and answered it {} where it was {} — this \
428             crate never changes a change request's draft state, so the host's answer is \
429             refused",
430            draft_word(after.draft),
431            draft_word(before.draft)
432        )));
433    }
434    if after.base != base {
435        return Err(invalid(format!(
436            "the host accepted moving {url} onto {base:?} and reports its base as {:?}",
437            after.base
438        )));
439    }
440    if request.remove_restored_base {
441        let expected = tips
442            .get(&from)
443            .map(|tip| tip.as_str().to_owned())
444            .ok_or_else(|| {
445                invalid(format!(
446                    "{url} moved onto {base:?}, and the restored base {from:?} is already gone \
447                     from the origin, so there was nothing to delete"
448                ))
449            })?;
450        let deleted = git::delete_remote_branch(&repository.publication, ORIGIN, &from, &expected)?;
451        if !deleted.accepted() {
452            return Err(Error::PushRejected {
453                reason: format!(
454                    "{url} moved onto {base:?}, and the origin declined to delete the restored \
455                     base {from:?}: {}",
456                    deleted.output().lines().next_back().unwrap_or("").trim()
457                ),
458            });
459        }
460    }
461    let outcome = if moves {
462        RetargetOutcome::Retargeted
463    } else {
464        RetargetOutcome::Unchanged
465    };
466    repository.record_change(
467        &url,
468        EventKind::ChangeRetargeted,
469        json!({
470            "change_url": url.to_string(),
471            "from": from,
472            "to": base,
473            "outcome": outcome_word(outcome),
474            "body_written": request.body.is_some(),
475        }),
476    );
477    Ok(Retargeted {
478        change: url,
479        from,
480        to: base,
481        outcome,
482        cost,
483    })
484}
485
486/// A closed change request to reopen, and the commit to restore its deleted base at.
487// llmlint: ignore[invalid_states_unrepresentable] the contract declares these fields;
488// `reopen_change` holds the commit to an object id this host can push and the URL to
489// `repo` before anything is written.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ReopenRequest {
492    /// The identity (normalized origin) or a registered checkout path.
493    pub repo: String,
494    /// The change request, by URL.
495    pub change: Url,
496    /// The commit its deleted base branch is restored at — the merged parent's last head.
497    pub restore_base_at: Sha,
498}
499
500/// What reopening a change request did.
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502pub struct Reopened {
503    /// The change request, as the host addresses it.
504    pub change: Url,
505    /// The base branch restored on the origin, which the caller retargets away from.
506    pub restored_base: String,
507    /// What the host charged for the call.
508    pub cost: ReadCost,
509}
510
511/// Restore a closed change request's deleted base at `restore_base_at` and reopen it,
512/// still a draft where it was one.
513///
514/// The library form of `onevcs change reopen`. Refused, with nothing written, for a
515/// change request that is not closed, a base branch the origin still holds, and a
516/// commit this host cannot reach; the restored branch is held to the public boundary
517/// before it is pushed. Records one `change-reopened` event, which is what
518/// [`retarget_change`]'s `remove_restored_base` is later held to.
519pub fn reopen_change(providers: &Providers<'_>, request: &ReopenRequest) -> Result<Reopened> {
520    let repository = Repository::of(providers, &request.repo)?;
521    let id = repository.id(&request.change)?;
522    let at = git::ObjectId::parse(&request.restore_base_at.0).ok_or_else(|| {
523        invalid(format!(
524            "{:?} is not a commit id; pass the full id of the commit to restore the base at",
525            request.restore_base_at.0
526        ))
527    })?;
528    let mut cost = ReadCost::default();
529    let before = repository.one(&id, "", &mut cost)?;
530    let url = before.url.clone();
531    if !matches!(before.state, ChangeState::Closed { .. }) {
532        return Err(invalid(format!(
533            "{url} is {}, not closed, so there is nothing to reopen. Nothing was written",
534            match before.state {
535                ChangeState::Open => "open",
536                _ => "merged",
537            }
538        )));
539    }
540    let base = branch_name(&before.base, "the change request's base")?;
541    if repository.origin_branches()?.contains_key(&base) {
542        return Err(invalid(format!(
543            "the origin of {} still holds {base:?}, the base of {url}, so there is no deleted \
544             base to restore. Nothing was written",
545            repository.key
546        )));
547    }
548    let commit = Sha(at.as_str().to_owned());
549    if !git::has_commit(repository.publication.as_path(), &commit)
550        && !git::fetch_commit(&repository.publication, ORIGIN, at.as_str())?
551    {
552        return Err(invalid(format!(
553            "{} is not a commit {} holds or can fetch from its origin, so {base:?} cannot be \
554             restored at it. Nothing was written",
555            at.as_str(),
556            repository.publication.display()
557        )));
558    }
559    let compared = format!(
560        "{ORIGIN}/{}",
561        git::default_branch(&repository.publication, ORIGIN)?
562    );
563    crate::boundary::evidence::guard_publication(
564        providers.hosting,
565        &repository.key,
566        &crate::boundary::screen::Outgoing {
567            repo: &repository.publication,
568            base: Some(compared.as_str()),
569            tip: at.as_str(),
570            branch: Some(base.as_str()),
571            title: None,
572            body: None,
573        },
574        &TermScope::default(),
575    )?;
576    let pushed = git::restore_remote_branch(&repository.publication, ORIGIN, &base, at.as_str())?;
577    if !pushed.accepted() {
578        return Err(Error::PushRejected {
579            reason: format!(
580                "the origin of {} declined restoring {base:?} at {}: {}. Nothing was reopened",
581                repository.key,
582                at.as_str(),
583                pushed.output().lines().next_back().unwrap_or("").trim()
584            ),
585        });
586    }
587    add(&mut cost, repository.host.reopen_change(&id)?);
588    let after = repository.one(&id, "", &mut cost)?;
589    if after.state != ChangeState::Open {
590        return Err(invalid(format!(
591            "the host accepted reopening {url} and still reports it {}; {base:?} stays restored \
592             at {}",
593            match after.state {
594                ChangeState::Closed { .. } => "closed",
595                _ => "merged",
596            },
597            at.as_str()
598        )));
599    }
600    if after.draft != before.draft {
601        return Err(invalid(format!(
602            "the host reopened {url} {} where it was {} — this crate never changes a change \
603             request's draft state, so the host's answer is refused",
604            draft_word(after.draft),
605            draft_word(before.draft)
606        )));
607    }
608    repository.record_change(
609        &url,
610        EventKind::ChangeReopened,
611        json!({
612            "change_url": url.to_string(),
613            "restored_base": base,
614            "at": at.as_str(),
615        }),
616    );
617    Ok(Reopened {
618        change: url,
619        restored_base: base,
620        cost,
621    })
622}
623
624/// The remote every registered checkout publishes through.
625const ORIGIN: &str = "origin";
626
627fn add(total: &mut ReadCost, more: ReadCost) {
628    total.graphql_points = total.graphql_points.saturating_add(more.graphql_points);
629    total.rest_requests = total.rest_requests.saturating_add(more.rest_requests);
630}
631
632fn draft_word(draft: bool) -> &'static str {
633    if draft {
634        "a draft"
635    } else {
636        "ready for review"
637    }
638}
639
640fn outcome_word(outcome: RetargetOutcome) -> &'static str {
641    match outcome {
642        RetargetOutcome::Retargeted => "retargeted",
643        RetargetOutcome::Unchanged => "unchanged",
644    }
645}
646
647/// A branch name a caller handed over, held to git's grammar before it reaches a host.
648fn branch_name(value: &str, what: &str) -> Result<String> {
649    Ref::try_from(value.to_owned())
650        .map(String::from)
651        .map_err(|reason| invalid(format!("{what} is {reason}")))
652}
653
654/// One repository, resolved as far as the host that answers for it.
655struct Repository {
656    host: Box<dyn RemoteHost>,
657    /// The identity's key, which every change request named must belong to.
658    key: String,
659    /// The checkout every publication fast-forwards, which asks the origin about its
660    /// branches and pushes a restored base.
661    publication: PathBuf,
662    registry: crate::registry::Registry,
663}
664
665impl Repository {
666    fn of(providers: &Providers<'_>, repo: &str) -> Result<Self> {
667        let registry = store::load()?;
668        let resolution = store::resolve(&registry, repo)?;
669        let slug = publish::change_host(&resolution.key)?;
670        Ok(Self {
671            host: providers.hosting.for_repo(&slug)?,
672            key: resolution.key,
673            publication: resolution.publication,
674            registry,
675        })
676    }
677
678    /// The host's id for one change request, refused when it is not one of this
679    /// repository's.
680    fn id(&self, url: &Url) -> Result<ChangeId> {
681        let parsed = review::ChangeUrl::parse(url.as_str())?;
682        if parsed.identity != self.key {
683            return Err(invalid(format!(
684                "{url} is a change request of {}, not of {}; every change request named must \
685                 be one of the repository the call names",
686                parsed.identity, self.key
687            )));
688        }
689        Ok(ChangeId(parsed.number))
690    }
691
692    fn ids<'a>(&self, urls: impl Iterator<Item = &'a Url>) -> Result<Vec<ChangeId>> {
693        urls.map(|url| self.id(url)).collect()
694    }
695
696    /// That the host answered exactly the change requests asked about, in order.
697    fn answered(&self, ids: &[ChangeId], standings: &[ChangeStanding]) -> Result<()> {
698        let numbers: Vec<Option<String>> = standings
699            .iter()
700            .map(|standing| {
701                review::ChangeUrl::parse(standing.url.as_str())
702                    .ok()
703                    .map(|url| url.number)
704            })
705            .collect();
706        let asked: Vec<Option<String>> = ids.iter().map(|id| Some(id.0.clone())).collect();
707        if numbers != asked {
708            return Err(invalid(format!(
709                "the host answered about {} change request(s) where {} were asked about, or not \
710                 in the order asked, so which standing is which cannot be read",
711                standings.len(),
712                ids.len()
713            )));
714        }
715        Ok(())
716    }
717
718    /// Where one change request stands, adding what the read cost to `cost`.
719    fn one(&self, id: &ChangeId, root: &str, cost: &mut ReadCost) -> Result<ChangeStanding> {
720        let read = self.host.change_standings(root, std::slice::from_ref(id))?;
721        self.answered(std::slice::from_ref(id), &read.standings)?;
722        add(cost, read.cost);
723        let standing = read.standings.into_iter().next().ok_or_else(|| {
724            invalid(format!(
725                "the host answered nothing about change request {}",
726                id.0
727            ))
728        })?;
729        record_head(&standing.url, &standing.head_branch);
730        Ok(standing)
731    }
732
733    /// Every branch the origin holds, and its tip — asked of the origin itself, through
734    /// the checkout every publication fast-forwards.
735    fn origin_branches(&self) -> Result<BTreeMap<String, git::ObjectId>> {
736        git::remote_branch_tips(&self.publication, ORIGIN)?.ok_or_else(|| {
737            invalid(format!(
738                "the origin of {} would not list its branches from {}",
739                self.key,
740                self.publication.display()
741            ))
742        })
743    }
744
745    /// Keep what a read saw — each head branch, and each merge into the root as the
746    /// landing it is — and answer the change requests whose landing this call recorded.
747    ///
748    /// Best effort throughout, as every record of something the host already did is:
749    /// the read answered, and a record that could not be written is said on stderr.
750    fn take_up(&self, standings: &[ChangeStanding], root: &str) -> Vec<String> {
751        let mut landed = Vec::new();
752        for standing in standings {
753            record_head(&standing.url, &standing.head_branch);
754            let ChangeState::Merged {
755                into,
756                commit,
757                landing: true,
758                merged_at,
759            } = &standing.state
760            else {
761                continue;
762            };
763            if into != root {
764                continue;
765            }
766            if self.record_landing(standing, commit, merged_at.as_deref()) {
767                landed.push(standing.url.to_string());
768            }
769        }
770        landed
771    }
772
773    /// Record one landing found on the host, unless a landing at the same commit is
774    /// already recorded for its branch. Answers whether this call recorded it.
775    fn record_landing(
776        &self,
777        standing: &ChangeStanding,
778        commit: &Sha,
779        merged_at: Option<&str>,
780    ) -> bool {
781        let warn = |failure: &dyn std::fmt::Display| {
782            eprintln!(
783                "onevcs: warning: the host reports {url} merged into the root at {commit}, and \
784                 this host could not record that landing for {branch} ({failure})",
785                url = standing.url,
786                commit = commit.0,
787                branch = standing.head_branch,
788            );
789            false
790        };
791        let (recorded, opened_on) = match status::landing_record(&self.key, &standing.head_branch) {
792            Ok(found) => found,
793            Err(failure) => return warn(&failure),
794        };
795        if recorded.as_deref() == Some(commit.0.as_str()) {
796            return false;
797        }
798        let located = match release::for_repository(&self.registry, &self.key) {
799            Ok(located) => located,
800            Err(failure) => return warn(&failure),
801        };
802        let mut releases = match Stream::releases(&self.key) {
803            Ok(stream) => stream,
804            Err(failure) => return warn(&failure),
805        };
806        let own = change_stream(standing.url.as_str());
807        let recording = match &opened_on {
808            Some(token) => release::LandingStream::Opened(token),
809            None => release::LandingStream::Own {
810                token: &own,
811                identity: &self.key,
812            },
813        };
814        release::reconcile_landing(
815            &self.registry,
816            &located,
817            &standing.head_branch,
818            Some(recording),
819            release::Landing {
820                commit: &commit.0,
821                at: merged_at.and_then(crate::gate_run::Moment::reported),
822            },
823            &standing.url,
824            &mut releases,
825        )
826    }
827
828    /// Record one `change-standings-read` on the repository's own stream. Best effort.
829    fn record_read(&self, count: usize, landed: &[String], cost: ReadCost) {
830        let token = format!("standings-{}", ids::short_digest(&self.key));
831        match Stream::open(&token) {
832            Ok(mut stream) => {
833                stream.label("identity", &self.key);
834                stream.emit(
835                    EventKind::ChangeStandingsRead,
836                    object(json!({
837                        "repo": self.key,
838                        "count": count,
839                        "landed": landed,
840                        "cost": {
841                            "graphql_points": cost.graphql_points,
842                            "rest_requests": cost.rest_requests,
843                        },
844                    })),
845                );
846            }
847            Err(error) => eprintln!(
848                "onevcs: warning: the standings read of {} is not recorded: {error}",
849                self.key
850            ),
851        }
852    }
853
854    /// Record one event about one change request on its own stream. Best effort.
855    fn record_change(&self, url: &Url, kind: EventKind, payload: serde_json::Value) {
856        match Stream::open(&change_stream(url.as_str())) {
857            Ok(mut stream) => {
858                stream.label("change_url", url.as_str());
859                stream.label("identity", &self.key);
860                stream.emit(kind, object(payload));
861            }
862            Err(error) => {
863                eprintln!("onevcs: warning: what was done on {url} is not recorded: {error}")
864            }
865        }
866    }
867}
868
869/// The stream a change request records on when nothing else names it — the one
870/// `review.rs` records a URL-named read on.
871pub(crate) fn change_stream(change_url: &str) -> String {
872    format!("change-{}", ids::short_digest(change_url))
873}
874
875/// Whether `reopen_change` restored `base` on this change request: a `change-reopened`
876/// on its own stream naming that base.
877fn restored_on(url: &Url, base: &str) -> Result<bool> {
878    let token = change_stream(url.as_str());
879    if !crate::stream::path_for(&token)?.is_file() {
880        return Ok(false);
881    }
882    let mut reader = crate::stream::Reader::open(&token)?;
883    Ok(reader.records()?.into_iter().any(|record| {
884        record.read.is_ok_and(|envelope| {
885            envelope.kind.as_str() == "change-reopened"
886                && envelope
887                    .payload
888                    .get("restored_base")
889                    .and_then(serde_json::Value::as_str)
890                    == Some(base)
891        })
892    }))
893}
894
895/// Where this host keeps what it last read of a change request's head branch.
896fn heads_dir() -> Result<PathBuf> {
897    Ok(home::root()?.join("change-heads"))
898}
899
900/// One record of a change request's head branch, as a read or a publication saw it.
901#[derive(Debug, Clone, Serialize, Deserialize)]
902struct HeadRecord {
903    url: String,
904    head_branch: String,
905    at: String,
906}
907
908/// Keep what a read saw of one change request's head branch. Best effort.
909pub(crate) fn record_head(url: &Url, head_branch: &str) {
910    let written = (|| -> Result<()> {
911        let directory = heads_dir()?;
912        home::ensure_dir(&directory)?;
913        let record = HeadRecord {
914            url: url.to_string(),
915            head_branch: head_branch.to_owned(),
916            at: ids::timestamp(),
917        };
918        let text = serde_json::to_string(&record)
919            .map_err(|e| invalid(format!("a head record could not be written: {e}")))?;
920        home::atomic_write(
921            &directory.join(format!("{}.json", ids::short_digest(url.as_str()))),
922            &text,
923        )
924    })();
925    if let Err(error) = written {
926        eprintln!("onevcs: warning: the head branch read for {url} is not recorded: {error}");
927    }
928}
929
930/// The newest record this host holds of which branch `url` carries — a standings read
931/// that saw its head, or a `change-opened` a publication wrote — or `None` where it
932/// holds none. Asked by a publication that defers its checks, which may ask the host
933/// nothing.
934pub(crate) fn recorded_head(url: &Url) -> Result<Option<String>> {
935    let mut candidates: Vec<(String, String)> = status::opened_heads(url.as_str())?;
936    let path = heads_dir()?.join(format!("{}.json", ids::short_digest(url.as_str())));
937    match std::fs::read_to_string(&path) {
938        // A record that is not what this crate writes is refused by name, never read as
939        // no record: a deferred publication decides whose change request it pushes into
940        // from it, and "could not read" must not become "nothing recorded".
941        Ok(text) => {
942            let record = serde_json::from_str::<HeadRecord>(&text)
943                .ok()
944                .filter(|record| {
945                    record.url == url.as_str()
946                        && ids::is_timestamp(&record.at)
947                        && Ref::try_from(record.head_branch.clone()).is_ok()
948                })
949                .ok_or_else(|| {
950                    invalid(format!(
951                        "the head record at {} is not one this build writes for {url}; remove \
952                         it, and a standings read of {url} writes it again",
953                        path.display()
954                    ))
955                })?;
956            candidates.push((record.at, record.head_branch));
957        }
958        Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => {}
959        Err(failure) => {
960            return Err(invalid(format!(
961                "the head record at {} could not be read: {failure}",
962                path.display()
963            )))
964        }
965    }
966    Ok(candidates
967        .into_iter()
968        .max_by(|left, right| left.0.cmp(&right.0))
969        .map(|(_, branch)| branch))
970}