Skip to main content

onevcs/
host.rs

1//! The remote-host side of the seam.
2//!
3//! Host-neutral vocabulary: the review unit is a [`ChangeRequest`]. GitHub maps it
4//! to a pull request; a later host maps it to whatever it calls the same thing.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::error::{invalid, Error, Result};
12use crate::event::ArtifactId;
13use crate::publish::DraftReason;
14use crate::rules::MergePolicy;
15use crate::{gh, git, stream};
16
17/// What `gh pr checks` exits with when a check it has just reported has not
18/// settled. The rollup it printed is still the answer, so this status is read
19/// alongside `0` rather than as a failure.
20const CHECKS_PENDING: i32 = 8;
21
22/// How `gh pr checks --required` opens its stderr when the repository declares no
23/// required check at all — a repository with no branch protection genuinely has
24/// none. The rest of that line names the branch, so only the prefix is fixed.
25const NO_REQUIRED_CHECKS: &str = "no required checks reported on the ";
26
27/// Whether a `gh pr checks` answer is one to read.
28fn usable(answer: &gh::Answer) -> bool {
29    matches!(answer.code, Some(0) | Some(CHECKS_PENDING))
30}
31
32/// How `gh pr checks` opens its stderr when the head carries no check **at all** —
33/// which is every head for the first seconds after it is pushed. The rest of that
34/// line names the branch, so only the prefix is fixed.
35const NO_CHECKS_YET: &str = "no checks reported on the ";
36
37/// Whether a failing `gh pr checks --required` failed by *answering* "none".
38///
39/// The whole shape, not a substring anywhere in the output: `gh` exits `1`, writes
40/// nothing to stdout, and opens stderr with [`NO_REQUIRED_CHECKS`]. Measured against
41/// real GitHub — a repository with no branch protection answers exactly that. It is
42/// held to all three because this is the one place a *failed* `gh` call is read as a
43/// meaningful answer, and a looser test would swallow an unrelated failure whose
44/// message happened to contain the phrase, reporting "nothing blocks the merge"
45/// about a host that never said so.
46fn no_required_checks(answer: &gh::Answer) -> bool {
47    answer.code == Some(1)
48        && answer.stdout.trim().is_empty()
49        && answer.stderr.trim_start().starts_with(NO_REQUIRED_CHECKS)
50}
51
52/// Whether a failing `gh pr checks` failed by reporting **no check at all** on the
53/// head, which is not an answer about what blocks the merge.
54///
55/// Held to the same three facts as [`no_required_checks`], and one word of `gh`'s own
56/// wording apart from it. Reading this one as that one would wave a merge through on
57/// a head no verification has begun on.
58fn no_checks_yet(answer: &gh::Answer) -> bool {
59    answer.code == Some(1)
60        && answer.stdout.trim().is_empty()
61        && answer.stderr.trim_start().starts_with(NO_CHECKS_YET)
62}
63
64/// How `gh api` reports a branch that has no classic protection at all — GitHub's
65/// own message, which is an answer (the source names nothing) and not a refusal.
66const NOT_PROTECTED: &str = "Branch not protected";
67
68/// Which of the host's check sources a call may consult.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum Consult {
71    /// The rollup, and Actions when the credential is not allowed to read it.
72    Either,
73    /// The complete status-check rollup only.
74    StatusChecks,
75    /// Actions jobs together with the branch rules that say which ones block.
76    Actions,
77}
78
79/// What [`gh::CHECK_SOURCE_ENV`] narrows a call to, or [`Consult::Either`].
80///
81/// The knob exists because the fallback is not free: a credential that will never
82/// be allowed to read check runs is refused *every* time the rollup is asked,
83/// which for a publication polling a change request is one failed call every few
84/// seconds. An operator who knows their token is a fine-grained one says so once.
85/// The value is checked here rather than acted on loosely — a misspelling that
86/// silently meant "auto" would look like the fallback working.
87fn consult() -> Result<Consult> {
88    let Some(raw) = std::env::var_os(gh::CHECK_SOURCE_ENV) else {
89        return Ok(Consult::Either);
90    };
91    match raw.to_string_lossy().trim().to_ascii_lowercase().as_str() {
92        "" | "auto" => Ok(Consult::Either),
93        "status-checks" => Ok(Consult::StatusChecks),
94        "actions" => Ok(Consult::Actions),
95        other => Err(invalid(format!(
96            "{} names {other:?}, which is not a check source this build can read: it must be \
97             \"auto\", \"status-checks\", or \"actions\"",
98            gh::CHECK_SOURCE_ENV
99        ))),
100    }
101}
102
103/// Read the operator's check-source knob and answer whether it names a source.
104///
105/// Exposed so a publication can refuse a misspelling of it **before** it pushes: the
106/// knob is input, and input is rejected at its boundary.
107pub(crate) fn check_source_names_a_source() -> Result<()> {
108    consult().map(|_| ())
109}
110
111/// How many entries an Actions listing is asked for at once, which is the most
112/// GitHub will return in one page.
113const PAGE: u32 = 100;
114
115/// How GitHub declines something the credential is not allowed to reach, in both
116/// the shapes it declines it in: a GraphQL error naming the node it would not
117/// produce, and a REST 403. The wording is GitHub's, and it is the whole of the
118/// test because it is the whole of what distinguishes the two cases below.
119const CHECK_RUN_REFUSAL: &str = "GraphQL: Resource not accessible by personal access token";
120
121/// Whether a refusal is one this credential will *always* get.
122///
123/// The one distinction the fallback turns on. A credential that may not read check
124/// runs will never read them, so asking a narrower source instead is the only way
125/// to answer at all — but a rollup that came back garbled, or a host that would not
126/// say which of its checks block, is a *complete* answer having gone wrong, and
127/// quietly answering from GitHub Actions alone would drop whatever a third-party
128/// integration posted. A required check nobody looked at is how a merge that was
129/// never gated ends up looking like one that was.
130fn check_rollup_refused_for_pat(error: &Error) -> bool {
131    let Error::Invalid { reason } = error else {
132        return false;
133    };
134    reason.contains(CHECK_RUN_REFUSAL) && reason.contains("statusCheckRollup")
135}
136
137/// Everything `onevcs` asks of a repository's remote host.
138pub trait RemoteHost {
139    /// Who the host believes is calling.
140    fn authenticated_user(&self) -> Result<String>;
141
142    /// Open a change request.
143    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest>;
144
145    /// Every open change request from `head` into `base`.
146    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>>;
147
148    /// The checks the host is reporting on a change request, and which of its
149    /// sources that answer was read from.
150    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks>;
151
152    /// Store one check's log as an artifact and return its id.
153    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId>;
154
155    /// Merge a change request under a policy.
156    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome>;
157
158    /// The commit a change request reached its base at, or `None` while the host
159    /// has not merged it.
160    ///
161    /// What a publication under `change-auto` watches. The host performs that merge
162    /// on its own clock — `merge` arms it and answers
163    /// [`MergeOutcome::Queued`] — so the only way to learn that it happened, and at
164    /// which commit, is to ask again. `None` is "not yet" and never "somewhere
165    /// else": an implementation that cannot say must refuse rather than answer it.
166    ///
167    /// Defaulted so that an implementation written against the earlier surface
168    /// still compiles, and defaulted to the refusal this crate reserves for a seam
169    /// with no body rather than to `None` — a host that was never taught to answer
170    /// this has not said a change is unmerged, and a publication that read it that
171    /// way would watch until its bound and then report checks that were never the
172    /// reason.
173    fn merged_at(&self, _cr: &ChangeRequest) -> Result<Option<Sha>> {
174        Err(Error::NotImplemented {
175            operation: "RemoteHost::merged_at",
176        })
177    }
178
179    /// Whether a change request can still merge into its base, as the host has
180    /// computed it.
181    ///
182    /// What a publication under `change-auto` reads beside
183    /// [`merged_at`](RemoteHost::merged_at) while it waits for the merge it armed: a
184    /// base that moved under the change so that it conflicts is a merge the host can
185    /// never perform, and [`Mergeability::Conflicting`] ends that wait at once as a
186    /// sync conflict rather than at the bound. [`Mergeability::Unknown`] is "not
187    /// computed yet" and is never read as a conflict.
188    ///
189    /// Defaulted for the reason [`merged_at`](RemoteHost::merged_at) is — the seam
190    /// stays additive — and to the same refusal. A publication reads that refusal as a
191    /// host that said nothing, which is no conflict, so a host written before this
192    /// method watches exactly as it did.
193    fn mergeability(&self, _cr: &ChangeRequest) -> Result<Mergeability> {
194        Err(Error::NotImplemented {
195            operation: "RemoteHost::mergeability",
196        })
197    }
198
199    /// Take a change request out of its draft state, so the host will let it land.
200    ///
201    /// What lifts a draft: a publication whose required checks came back green on it
202    /// (or whose grace window elapsed with none run), `onevcs change ready`, and a
203    /// publication adopting a draft with the lifecycle off. It is always asked
204    /// **before** the host is asked to merge or arm a merge, because a host does
205    /// neither to a draft.
206    ///
207    /// Defaulted for the reason [`merged_at`](RemoteHost::merged_at) is — the seam
208    /// stays additive — and to the same refusal: a host that was never taught to
209    /// lift a draft has not lifted one, and answering `Ok(())` would report a change
210    /// as ready for review while the host goes on holding it.
211    fn ready_for_review(&self, _cr: &ChangeRequest) -> Result<()> {
212        Err(Error::NotImplemented {
213            operation: "RemoteHost::ready_for_review",
214        })
215    }
216
217    /// Whether the host is holding this change request as a draft.
218    ///
219    /// Asked on both sides of a draft's life: after one is opened, because a host
220    /// that ignored the request would otherwise leave a landable change reported as
221    /// held back; and before one is lifted, so a change nobody drafted is asked for
222    /// nothing and a second lift changes nothing.
223    ///
224    /// Defaulted to the refusal, never to `false`: "this host was never taught to
225    /// answer" and "this change is not a draft" are different facts, and only the
226    /// second is a reason to go on and merge — which is what the `Result` around the
227    /// answer carries, rather than a third value inside it.
228    ///
229    /// The answer is a `bool` because the approved amendment in `docs/contract.md`
230    /// declares this seam verbatim as `-> Result<bool>`: a named domain type here would
231    /// be a public item the contract does not name, which this repository reports rather
232    /// than takes. Nothing is lost to the shape — the *third* state a caller must not
233    /// confuse with "not a draft" is "this host would not say", and that is the `Err`
234    /// arm rather than a value inside the `Ok`. Both call sites read it as one of three,
235    /// and they are `hold_as_draft` and `lift_any_draft` in `publish.rs`.
236    // llmlint: ignore[invalid_states_unrepresentable] the doc above: the contract fixes this seam.
237    fn is_draft(&self, _cr: &ChangeRequest) -> Result<bool> {
238        Err(Error::NotImplemented {
239            operation: "RemoteHost::is_draft",
240        })
241    }
242
243    /// The checks the repository requires before anything merges into `base`, asked
244    /// of the branch itself rather than of a change request targeting it.
245    ///
246    /// What `onevcs repos --audit-gates` names per identity. Which checks a
247    /// repository requires is a setting on that repository, and a consumer that
248    /// sequences its own work behind another repository's merge path had to keep a
249    /// copy of that list — which drifted the day the repository renamed a check, and
250    /// cost a whole gate to discover. The host holds the list; this is how it is
251    /// read.
252    ///
253    /// The answer says which protection source it could not consult, because "this
254    /// source found nothing" and "this merge path requires nothing" are opposite
255    /// facts and only the second is safe to act on: an answer with a source in
256    /// [`RequiredChecks::unconsulted`] is incomplete, and an empty one is *unknown*
257    /// rather than none. A host that could read no source at all must refuse rather
258    /// than answer, for the reason [`change_checks`](RemoteHost::change_checks) may
259    /// not answer with an empty source list.
260    ///
261    /// Defaulted for the reason [`merged_at`](RemoteHost::merged_at) is — the seam
262    /// stays additive — and to the same refusal, which the audit reports as the list
263    /// being unreadable rather than as no check being required.
264    fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
265        Err(Error::NotImplemented {
266            operation: "RemoteHost::required_checks_on",
267        })
268    }
269
270    /// Replace a change request's description: its body, and its title when one is
271    /// given.
272    ///
273    /// The one write to a change request's prose after it exists. It writes exactly
274    /// the caller's bytes — nothing is composed around the body and nothing of a
275    /// draft's reason is rendered into it — because a body is what a reviewer reads
276    /// and what a drafting caller may rewrite, and `onevcs change describe` is where
277    /// a session's own evidence reaches it.
278    ///
279    /// Defaulted for the reason [`merged_at`](RemoteHost::merged_at) is — the seam
280    /// stays additive — and to the same refusal: a host that was never taught to
281    /// describe a change has not described one.
282    fn describe_change(
283        &self,
284        _cr: &ChangeRequest,
285        _title: Option<&str>,
286        _body: &str,
287    ) -> Result<()> {
288        Err(Error::NotImplemented {
289            operation: "RemoteHost::describe_change",
290        })
291    }
292
293    /// What a change request's description is now: its title and its body, as the
294    /// host holds them.
295    ///
296    /// The read beside [`describe_change`](RemoteHost::describe_change), and what
297    /// `onevcs change show` answers with: a caller that wrote a description needs to
298    /// see what it wrote, and a caller finishing one off needs to see what it starts
299    /// from.
300    ///
301    /// Defaulted to the refusal for the reason every other added method is.
302    fn change_description(&self, _cr: &ChangeRequest) -> Result<Description> {
303        Err(Error::NotImplemented {
304            operation: "RemoteHost::change_description",
305        })
306    }
307}
308
309/// Whether a change request can merge into its base, as
310/// [`RemoteHost::mergeability`] answers it.
311///
312/// `#[non_exhaustive]` so that a later state a host reports — a change that merges
313/// cleanly and is blocked for another reason, say — is not a breaking change. A caller
314/// matching on it reads anything it does not name as "not a conflict".
315#[derive(Debug, Clone, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum Mergeability {
318    /// The host computed it, and the change merges into its base as it stands.
319    Mergeable,
320    /// The host confirms the change conflicts with its base, so it cannot merge until
321    /// the branch is reconciled with it.
322    Conflicting {
323        /// What the host reported, in its own words — on GitHub `mergeable` and
324        /// `mergeStateStatus` — which is what a refusal about it quotes.
325        reported: String,
326    },
327    /// The host has not computed it yet, which is never a conflict.
328    Unknown,
329}
330
331/// A change request's description as the host holds it.
332///
333/// What [`RemoteHost::change_description`] answers, and the other half of
334/// [`ChangeDescription`](crate::ChangeDescription): a caller writes a body and
335/// optionally a title, and reads back both. Both are plain strings, because both are
336/// prose a host places no shape on — a title *this crate lands* is a
337/// [`Subject`](crate::Subject), but a title a host already holds was composed by
338/// whoever opened the change request, and is reported as it is rather than refused.
339// llmlint: ignore[invalid_states_unrepresentable] the doc above: what a host answers,
340// reported rather than judged.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct Description {
343    /// The title, which under squash-merge becomes the commit subject.
344    pub title: String,
345    /// The body, verbatim. Empty where the change request carries none.
346    pub body: String,
347}
348
349/// One of the ways a host protects a branch, each of which may name required checks.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
351#[serde(rename_all = "kebab-case")]
352pub enum ProtectionSource {
353    /// The repository's rulesets, which every credential that can read the
354    /// repository can read.
355    Rulesets,
356    /// Classic branch protection, which only a credential with administration
357    /// rights can read — so it is the source an audit is most often refused.
358    BranchProtection,
359}
360
361impl ProtectionSource {
362    /// Every source, in the order a report names them.
363    pub fn every() -> impl Iterator<Item = ProtectionSource> {
364        [
365            ProtectionSource::Rulesets,
366            ProtectionSource::BranchProtection,
367        ]
368        .into_iter()
369    }
370
371    /// How a report names it.
372    pub fn describe(self) -> &'static str {
373        match self {
374            ProtectionSource::Rulesets => "the repository's rulesets",
375            ProtectionSource::BranchProtection => "classic branch protection",
376        }
377    }
378}
379
380/// What a host requires before a merge into a branch, and how much of the host
381/// that answer covers.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct RequiredChecks {
384    /// The names, from every source that answered.
385    pub checks: BTreeSet<String>,
386    /// Each protection source the host holds that could not be read, with the
387    /// host's reason. Empty means every source answered and `checks` is the whole
388    /// list; otherwise `checks` is what the sources that did answer require, and an
389    /// empty `checks` is not "nothing is required".
390    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
391    pub unconsulted: BTreeMap<ProtectionSource, String>,
392}
393
394impl RequiredChecks {
395    /// Whether every protection source answered, so `checks` is the whole list.
396    pub fn complete(&self) -> bool {
397        self.unconsulted.is_empty()
398    }
399}
400
401/// Where a [`RemoteHost`] for one repository comes from.
402///
403/// A host is addressed at a repository rather than at an installation — every
404/// call `gh` makes carries a slug — so the seam a caller supplies is the factory
405/// rather than one host object. This is what makes the interface reachable: a
406/// publication asks the factory it was handed for the repository it is publishing
407/// to, and never names an implementation.
408pub trait Hosting {
409    /// The host that answers for the repository named `owner/name`.
410    // llmlint: ignore[invalid_states_unrepresentable] a validated slug newtype would be a
411    // public item beyond the one this seam is specified as, and it would have exactly one
412    // constructor — the check below. That check is where the value is decided: `GitHub::new`
413    // refuses anything that does not name one repository as `owner/name`, before the slug is
414    // interpolated into a single `gh --repo`. A caller cannot reach a host any other way.
415    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>>;
416}
417
418/// The factory that produces [`GitHub`] hosts, which is what a real run uses.
419///
420/// Private: the way to hold one is [`crate::Providers::real`], because a caller
421/// mixing a real GitHub into a run whose repository side is not git is asking for
422/// a combination neither half was written for.
423#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
424pub(crate) struct GitHubHosting;
425
426impl Hosting for GitHubHosting {
427    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
428        Ok(Box::new(GitHub::new(slug)?))
429    }
430}
431
432/// What to open a change request for.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct ChangeSpec {
435    /// The branch carrying the change.
436    // llmlint: ignore[invalid_states_unrepresentable] a validated branch-ref newtype is
437    // exactly what the publication path carries internally, and it cannot surface here:
438    // the contract fixes the matching `ChangeRequest.base` as `String` and names no ref
439    // type, so spelling one on this side of the same trait would add a public item the
440    // contract does not name and disagree with the type it does. Both names are handed to
441    // git before they reach a host, which is where the parser that decides them lives.
442    pub head: String,
443    /// The branch it targets, which for a stacked change is the branch below it.
444    pub base: String,
445    /// The title, which under squash-merge becomes the commit subject.
446    pub title: String,
447    /// The body. Absent means the host's default from the repository template.
448    pub body: Option<String>,
449    /// Open it as a **draft**, and why it is not ready. Absent opens an ordinary
450    /// change request, which is every publication that came before this field.
451    ///
452    /// The host is handed the whole reason and asked for one thing with it: open the
453    /// change as a draft. Nothing here renders the reason into the change request —
454    /// see [`DraftReason`] for the ruling that keeps it out of the body — so a host
455    /// implementation that reads past this field's presence is reading further than
456    /// the contract asks it to.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub draft: Option<DraftReason>,
459    /// Open it as a draft **while its required checks run**, with no
460    /// [`DraftReason`]: what a lifecycle publication asks for when its caller asked
461    /// for no draft. A host lifts or keeps it once the checks settle, by the rules
462    /// the publication states; nothing here is a reason a caller gave.
463    ///
464    /// Beside `draft` rather than a third kind of it, because [`DraftReason`] is what
465    /// a *caller* asks for and this is what the lifecycle asks for when the caller
466    /// asked for none. Defaulted and omitted when false, so a spec written before it
467    /// reads and writes as it did. Where both are set the reason is what the host
468    /// records: either way the change opens as a draft.
469    // llmlint: ignore[invalid_states_unrepresentable] one sum type over both would
470    // change `draft`, which the approved contract declares verbatim as
471    // `Option<DraftReason>`, and the manager's ruling for this amendment forbids widening
472    // `DraftReason` itself. So the lifecycle's draft is an additive, defaulted field beside
473    // it, and the one combination both allow is given a meaning above rather than refused.
474    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
475    pub draft_awaiting_checks: bool,
476}
477
478/// An open change request on the host.
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct ChangeRequest {
481    /// The host's identifier for it.
482    pub id: ChangeId,
483    /// Where a human reads it.
484    pub url: Url,
485    /// The commit its checks are reported against.
486    pub head_sha: Sha,
487    /// The branch it targets.
488    // llmlint: ignore[invalid_states_unrepresentable] the contract declares this field
489    // verbatim as `pub base: String` and names no ref type to narrow it to; changing it
490    // is changing the interface, which is reported rather than done. Every one this crate
491    // constructs is read out of a host response naming a branch the host already has.
492    pub base: String,
493}
494
495/// A host's identifier for a change request.
496// llmlint: ignore[invalid_states_unrepresentable] the contract declares this as
497// `id: ChangeId` and fixes nothing about its content — GitHub numbers its pull
498// requests, and another host may not. Every one this crate constructs is read out of
499// a host response that is required to carry it: `find_changes` rejects an entry with
500// no number, and `open_change` rejects output that printed no URL to take one from.
501#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
502#[serde(transparent)]
503pub struct ChangeId(pub String);
504
505/// A commit hash.
506// llmlint: ignore[invalid_states_unrepresentable] the contract declares this field as
507// `head_sha: Sha` and fixes nothing about its content, and a hash's shape is the host's
508// to state — a SHA-1 hex string today, something else on a repository that has moved.
509// Every one this crate constructs is validated where it enters: `head_sha` and
510// `merged_sha` below both reject a response that names no commit rather than
511// constructing an empty one, so no code path here can produce a blank `Sha`.
512#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
513#[serde(transparent)]
514pub struct Sha(pub String);
515
516/// One check the host reports on a change request.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct Check {
519    /// The check's name, as branch protection lists it.
520    pub name: String,
521    /// Where the check is: the host's own status vocabulary, passed through.
522    // llmlint: ignore[invalid_states_unrepresentable] the contract fixes this field name
523    // and enumerates no value set for it, and the vocabulary differs per host — which is
524    // the thing this crate exists to abstract. Inventing an enum here would add a public
525    // item the contract does not name. The one word this build reads out of it —
526    // `completed` — is read in `settled` below and nowhere else, so a host that spells
527    // its states differently is understood by a second implementation of that predicate
528    // rather than by a new variant here. Recorded as open question 1 in
529    // docs/inferred-surface.md for the planner to settle across the three repositories.
530    pub status: String,
531    /// How it ended, once it has. Absent while it is still running.
532    // llmlint: ignore[invalid_states_unrepresentable] `conclusion` is the other half of the
533    // same open question as `status` above, for the same reason: the contract names the
534    // field and enumerates no conclusion vocabulary, and each host spells its own. The
535    // three this build treats as not blocking a merge are read in `green` below, which is
536    // the one place a second host's vocabulary would be taught.
537    pub conclusion: Option<String>,
538    /// Whether it blocks the merge.
539    pub required: bool,
540    /// The commit the host attached this check to, or `None` where the host did not
541    /// say which one it is about.
542    ///
543    /// What makes a check's verdict addressable to a commit rather than to a change
544    /// request. A change request is a moving target — every push gives it a new head,
545    /// and the host attaches the new head's checks seconds to minutes later — so a
546    /// caller reading "the checks on this change request" moments after a push reads
547    /// the *previous* head's answer and cannot tell. `None` is the host declining to
548    /// say and never a commit inferred from the change request: a caller that must
549    /// distinguish them has to be able to, and one that cannot is told nothing rather
550    /// than told wrongly.
551    ///
552    /// Defaulted, so a check some earlier build serialized still reads — as one whose
553    /// commit that build never recorded, which is what it is.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub head: Option<Sha>,
556    /// Where this check is on the host, so whoever is handed its refusal can go and
557    /// look at it. `None` where the host reported no address for it.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub url: Option<Url>,
560    /// When the host says this run of the check started, spelled as the host spelled
561    /// it, or `None` where it did not say.
562    ///
563    /// The run's own identity: a check re-run under the same name — the run a
564    /// workflow starts when a draft is lifted — is a new run with a new start, even
565    /// where it concludes exactly as the one before it did. That is what a
566    /// publication tells a run the host attached after an early lift from the
567    /// draft's by, so it is compared for equality and never parsed or ordered: two
568    /// clocks never meet in it.
569    ///
570    /// Defaulted and omitted when empty, as `head` and `url` are, so a check an
571    /// earlier build serialized still reads and one without a start serializes as
572    /// it always did.
573    // llmlint: ignore[invalid_states_unrepresentable] the host's own timestamp, carried
574    // as an opaque token: this crate only ever asks whether two runs report the same
575    // one, so parsing it would add a failure mode and decide nothing. `reported_start`
576    // is the one place it enters, and it drops GitHub's zero time for a run that has
577    // not started rather than letting every queued run share one identity.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub started_at: Option<String>,
580}
581
582impl Check {
583    /// Whether this check has reached a terminal state.
584    pub fn settled(&self) -> bool {
585        self.status.eq_ignore_ascii_case("completed")
586    }
587
588    /// Which of the five states this check is in — the one classifier every reading
589    /// and every rendering of a check follows.
590    ///
591    /// A skipped check is its own state and never a passed one: a job a workflow
592    /// gated on the change not being a draft reports `skipped` without having run,
593    /// so reading it as green would call a change verified that nothing verified.
594    /// What the publication watch does with a skipped *required* check is its own
595    /// rule, stated where the watch is.
596    pub fn state(&self) -> CheckState {
597        if !self.settled() {
598            return CheckState::Pending;
599        }
600        match self
601            .conclusion
602            .as_deref()
603            .map(str::to_ascii_lowercase)
604            .as_deref()
605        {
606            Some("success" | "neutral") => CheckState::Passed,
607            Some("skipped") => CheckState::Skipped,
608            Some("cancelled" | "stale") => CheckState::NoVerdict,
609            _ => CheckState::Failed,
610        }
611    }
612
613    /// Whether a settled check passed. A skipped one did not: see [`Check::state`].
614    pub fn green(&self) -> bool {
615        self.state() == CheckState::Passed
616    }
617
618    /// Whether the host ended this run without a verdict either way.
619    pub(crate) fn no_verdict(&self) -> bool {
620        self.state() == CheckState::NoVerdict
621    }
622
623    /// Whether a settled check ended in a way that blocks a merge.
624    pub fn red(&self) -> bool {
625        self.state() == CheckState::Failed
626    }
627}
628
629/// Which of five states one check is in, as [`Check::state`] classifies it.
630///
631/// Exported so a consumer holds its own copy of "what reads as green" to this
632/// declaration rather than to a list it keeps beside it. Serialized kebab-case, which
633/// is how the `change-check` event's `state`, the `checks-settled` event and `onevcs
634/// status` spell it.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
636#[serde(rename_all = "kebab-case")]
637pub enum CheckState {
638    /// Concluded `success` or `neutral`.
639    Passed,
640    /// Concluded in a way that blocks a merge: every settled conclusion not named by
641    /// another state.
642    Failed,
643    /// Concluded `skipped`: it did not run, and it is never read as passed.
644    Skipped,
645    /// Not settled yet.
646    Pending,
647    /// Concluded `cancelled` or `stale`: the host ended the run with no verdict.
648    NoVerdict,
649}
650
651/// One place a host's answer about a change request's checks was read from.
652///
653/// A credential decides which of these can be reached, so which were consulted is
654/// part of the answer rather than a property of the build: a fine-grained personal
655/// access token cannot read check runs *at all* — GitHub offers no `Checks`
656/// permission for one, and the permission it briefly had was withdrawn — so for
657/// such a token the rollup is unreadable and Actions is everything it can see.
658#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
659#[serde(rename_all = "kebab-case")]
660pub enum CheckSource {
661    /// The host's own rollup of every check on the change request: workflow jobs,
662    /// check runs a third-party integration posted, and commit statuses alike,
663    /// each carrying whether it blocks the merge. Complete, and the one source a
664    /// fine-grained token cannot read.
665    StatusChecks,
666    /// GitHub Actions' own view of the head commit: the jobs of the workflow runs
667    /// on it and nothing else. A check that ran no workflow — anything a
668    /// third-party integration posted as a check run or a commit status — is not
669    /// in it and cannot be seen from it.
670    Actions,
671    /// The repository's rulesets, for which checks block a merge into the base.
672    /// Narrower than the rollup's own answer: it reports rulesets and not classic
673    /// branch protection, so a repository protected the classic way reports
674    /// nothing blocking here — which the publication path fails closed on, waiting
675    /// for a required check that never arrives rather than merging without one.
676    BranchRules,
677}
678
679/// What a host reports about a change request's checks, and where it looked.
680///
681/// The sources travel with the checks because a caller deciding whether a change
682/// may merge has to be able to tell "these are all the checks" from "these are the
683/// workflow checks, and anything a third-party integration posted is invisible to
684/// this credential". Reporting the second as the first is how a merge gets waved
685/// through by a check nobody could see.
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687pub struct ChangeChecks {
688    /// The checks themselves.
689    pub checks: Vec<Check>,
690    /// Every source consulted to produce them. Never empty: a host that could read
691    /// none of its sources is a refusal rather than an answer.
692    // llmlint: ignore[invalid_states_unrepresentable] the approved contract fixes
693    // this as a public BTreeSet; implementations refuse an empty answer.
694    pub sources: BTreeSet<CheckSource>,
695}
696
697impl ChangeChecks {
698    /// Whether this answer covers every check the host reports.
699    ///
700    /// False means the checks above are GitHub Actions' and only those.
701    pub fn complete(&self) -> bool {
702        self.sources.contains(&CheckSource::StatusChecks)
703    }
704}
705
706/// What merging a change request did.
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(rename_all = "kebab-case")]
709pub enum MergeOutcome {
710    /// It merged, at this commit.
711    Merged(Sha),
712    /// The host queued it and will merge it once its checks pass.
713    Queued,
714    /// It was left open for review, which the policy asked for.
715    Open,
716}
717
718/// The GitHub implementation of [`RemoteHost`], driven through `gh`.
719///
720/// Every method is one `gh` invocation, and the program that answers as `gh` is the
721/// seam a journey substitutes: GitHub's decisioning is what a test replaces, never
722/// the git underneath it.
723#[derive(Debug, Clone, PartialEq, Eq)]
724pub struct GitHub {
725    /// The `owner/name` slug every call is addressed to. Private, so the only way
726    /// to hold one is to have had it accepted by [`GitHub::new`] — every `gh`
727    /// invocation below trusts it as the repository it addresses.
728    repo: String,
729}
730
731impl GitHub {
732    /// Address this host at one repository, named `owner/name`.
733    ///
734    /// Checked here rather than at each call: the slug is interpolated into every
735    /// `gh --repo` invocation, and a value that is not one repository is a value
736    /// that addresses something nobody asked for.
737    pub fn new(repo: impl Into<String>) -> Result<Self> {
738        let repo = repo.into();
739        let mut parts = repo.split('/');
740        let named = matches!(
741            (parts.next(), parts.next(), parts.next()),
742            (Some(owner), Some(name), None)
743                if !owner.is_empty()
744                    && !name.is_empty()
745                    && !repo.starts_with('-')
746                    && !repo.contains(char::is_whitespace)
747        );
748        if !named {
749            return Err(invalid(format!(
750                "{repo:?} does not name one repository as owner/name"
751            )));
752        }
753        Ok(Self { repo })
754    }
755
756    /// The names of the checks the host says block the merge.
757    ///
758    /// A second call, and it has to be one: `gh pr view`'s `statusCheckRollup`
759    /// carries a check's name, status, and conclusion and *nothing* about whether it
760    /// blocks anything. This build asked it for `isRequired` anyway and read the
761    /// field's absence as a host answering partially, so every `change_checks`
762    /// against real GitHub failed — a field `gh pr view` has never returned, checked
763    /// only ever against a stand-in that returned it. `gh pr checks --required` is
764    /// where `gh` reports it.
765    fn required_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
766        addressable(&cr.id.0, "change request id")?;
767        let answer = gh::attempt(&[
768            "pr",
769            "checks",
770            &cr.id.0,
771            "--repo",
772            &self.repo,
773            "--required",
774            "--json",
775            "name",
776        ])?;
777        if !usable(&answer) {
778            // A repository that declares no required check at all is answering, not
779            // failing: refusing here would make every unprotected repository
780            // unreadable, and "nothing blocks the merge" is what the publication
781            // path already knows how to act on.
782            if no_required_checks(&answer) {
783                return Ok(BTreeSet::new());
784            }
785            if no_checks_yet(&answer) {
786                return Err(unregistered(&cr.url, answer.stderr.trim()));
787            }
788            return Err(unsaid(&cr.url, &answer.detail()));
789        }
790        let value = gh::json(&answer.stdout)?;
791        let entries = value
792            .as_array()
793            .ok_or_else(|| unsaid(&cr.url, &value.to_string()))?;
794        let mut names = BTreeSet::new();
795        for entry in entries {
796            let name = entry
797                .get("name")
798                .and_then(|value| value.as_str())
799                .filter(|value| !value.is_empty())
800                .ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
801            names.insert(name.to_owned());
802        }
803        Ok(names)
804    }
805
806    /// The log of the job one check ran in.
807    ///
808    /// Two calls, because `gh` addresses a job's log by the job's *id* and a check's
809    /// name is not one. This build passed the name to `gh run view --job`, which has
810    /// never accepted anything but a number — so the artifact every settled check
811    /// carried held a refusal to produce a log rather than the log, and no offline
812    /// journey could tell, because the program standing in for `gh` answered to the
813    /// name. The id is the last segment of the details URL `gh pr checks` reports.
814    fn job_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
815        addressable(&cr.id.0, "change request id")?;
816        let answer = gh::attempt(&[
817            "pr",
818            "checks",
819            &cr.id.0,
820            "--repo",
821            &self.repo,
822            "--json",
823            "name,link",
824        ])?;
825        if !usable(&answer) {
826            return Err(invalid(format!(
827                "gh pr checks would not say where check {name:?} on {} ran: {}",
828                cr.url,
829                answer.detail()
830            )));
831        }
832        let value = gh::json(&answer.stdout)?;
833        // Rejected here rather than searched through: a host that answered with
834        // something other than a list of checks has not said this check has no job,
835        // and reporting it as though it had would put the wrong reason in the
836        // artifact an operator reads to find out why there is no log.
837        let entries = value.as_array().ok_or_else(|| {
838            invalid(format!(
839                "gh pr checks returned a non-list of checks on {}, so where check {name:?} ran \
840                 cannot be read from it: {value}",
841                cr.url
842            ))
843        })?;
844        let link = entries
845            .iter()
846            .find(|entry| entry.get("name").and_then(|value| value.as_str()) == Some(name))
847            .and_then(|entry| entry.get("link"))
848            .and_then(|value| value.as_str())
849            .ok_or_else(|| {
850                invalid(format!(
851                    "the host reports no job for check {name:?} on {}",
852                    cr.url
853                ))
854            })?;
855        let job = link
856            .rsplit_once("/job/")
857            .map(|(_, id)| id)
858            .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))
859            .ok_or_else(|| {
860                invalid(format!(
861                    "the host says check {name:?} ran at {link:?}, which names no job this build \
862                     can ask for a log"
863                ))
864            })?;
865        gh::invoke_content(&["run", "view", "--repo", &self.repo, "--log", "--job", job])
866    }
867
868    /// One check's log, from whichever source this credential can read.
869    ///
870    /// The same order [`RemoteHost::change_checks`] consults them in, for the same
871    /// reason: `gh pr checks` reports where *every* check ran, including one no
872    /// workflow produced, and the Actions API answers for the ones it owns when the
873    /// credential may not read the rollup at all.
874    fn log_of(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
875        match consult()? {
876            Consult::StatusChecks => self.job_log(cr, name),
877            Consult::Actions => self.actions_log(cr, name),
878            // As above, and for the same reason: where a check ran is a question
879            // `gh pr checks` can answer about every check, and only a credential
880            // that may not ask it at all has cause to ask a narrower source.
881            Consult::Either => self.job_log(cr, name).or_else(|reported| {
882                if !check_rollup_refused_for_pat(&reported) {
883                    return Err(reported);
884                }
885                self.actions_log(cr, name).map_err(|actions| {
886                    invalid(format!(
887                        "neither of GitHub's check sources would produce the log of check \
888                         {name:?} on {}: gh pr checks answered {reported}, and the Actions API \
889                         answered {actions}",
890                        cr.url
891                    ))
892                })
893            }),
894        }
895    }
896
897    /// Every check the host's own rollup reports on a change request.
898    ///
899    /// Complete — a workflow job, a check run some integration posted, and a commit
900    /// status all appear here — and readable only by a credential the repository
901    /// allows to read its check runs. A fine-grained personal access token is not
902    /// one and cannot become one, which is what [`GitHub::actions_checks`] answers
903    /// instead.
904    fn rollup_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
905        // `headRefOid` in the same call, because the rollup GitHub renders is the
906        // rollup of the change request's *last commit* as that one response saw it —
907        // and which commit that was is the whole of what makes the answer
908        // addressable. Asked together rather than in a second call for the same
909        // reason a second call could not answer it: the head moves, so a commit read
910        // afterwards is not necessarily the one those checks were about. It costs
911        // this call nothing, because the field needs no more than the read access
912        // that found the change request while `statusCheckRollup` needs far more —
913        // a credential refused for one of them was already refused for the call.
914        let value = self.view(&cr.id.0, "headRefOid,statusCheckRollup")?;
915        let head = reported_sha(&value, "headRefOid")?;
916        let reported = value.get("statusCheckRollup").ok_or_else(|| {
917            invalid(format!(
918                "gh pr view reported no checks at all on {}",
919                cr.url
920            ))
921        })?;
922        if reported.is_null() {
923            return Ok(Vec::new());
924        }
925        let rollup = reported
926            .as_array()
927            .ok_or_else(|| invalid(format!("gh pr view returned a non-list rollup: {reported}")))?;
928        if rollup.is_empty() {
929            return Ok(Vec::new());
930        }
931        let required = self.required_checks(cr)?;
932        rollup
933            .iter()
934            .map(|entry| check(entry, cr, &required, head.as_ref()))
935            .collect()
936    }
937
938    /// The checks GitHub Actions reports for the change request's head commit.
939    ///
940    /// One workflow job is one check, which is the same unit the rollup reports
941    /// them in — GitHub posts a check run per job — so what a caller waits for is
942    /// unchanged. What it cannot see is anything that ran no workflow, which is why
943    /// the answer says which sources it came from rather than passing itself off as
944    /// the whole picture.
945    fn actions_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
946        let jobs = self.actions_jobs(cr)?;
947        if jobs.is_empty() {
948            // Nothing has run yet, which is the first seconds of every change
949            // request. There is no check to ask the rulesets about, so they are not
950            // asked and the answer does not claim they were.
951            return Ok(ChangeChecks {
952                checks: Vec::new(),
953                sources: [CheckSource::Actions].into_iter().collect(),
954            });
955        }
956        let required = self.ruled_checks(cr)?;
957        Ok(ChangeChecks {
958            checks: jobs
959                .into_iter()
960                .map(|job| Check {
961                    required: required.contains(&job.name),
962                    name: job.name,
963                    status: job.status,
964                    conclusion: job.conclusion,
965                    head: job.head,
966                    url: job.url,
967                    started_at: job.started_at,
968                })
969                .collect(),
970            sources: [CheckSource::Actions, CheckSource::BranchRules]
971                .into_iter()
972                .collect(),
973        })
974    }
975
976    /// Every job of every workflow run on the change request's head commit.
977    ///
978    /// Two calls per run, because the Actions API reports a *run*'s status at the
979    /// run and a job's at the job, and a run that is still going says nothing about
980    /// which of its jobs have finished — and one before them, for which commit that
981    /// head is *now*. Asked rather than taken off the [`ChangeRequest`] the caller
982    /// holds, because that one was read when the change request was found: a
983    /// publication finds it moments after pushing, when the host may not have
984    /// processed the push, and a build that kept asking about the head it first saw
985    /// would go on asking about a commit this run replaced for as long as it
986    /// watched. The rollup source re-reads the same field on every call for the same
987    /// reason; this is that, on the source a fine-grained token is left with.
988    fn actions_jobs(&self, cr: &ChangeRequest) -> Result<Vec<Job>> {
989        let current = head_sha(&self.view(&cr.id.0, "headRefOid")?)?;
990        let sha = commit(&current)?;
991        let runs = self.api(&format!(
992            "repos/{}/actions/runs?head_sha={sha}&per_page={PAGE}",
993            self.repo
994        ))?;
995        let mut jobs = Vec::new();
996        for run in listed(&runs, "workflow_runs", &format!("the commit {sha}"))? {
997            let id = run
998                .get("id")
999                .and_then(serde_json::Value::as_u64)
1000                .ok_or_else(|| {
1001                    invalid(format!(
1002                        "the Actions API reported a workflow run on {} with no id: {run}",
1003                        cr.url
1004                    ))
1005                })?;
1006            // The commit off the run's own record rather than the one this query
1007            // filtered on: what the host says it ran against is the host's answer,
1008            // and a build that stamped the question onto the answer could not tell a
1009            // host that disagreed.
1010            let head = reported_sha(run, "head_sha")?;
1011            let listing = self.api(&format!(
1012                "repos/{}/actions/runs/{id}/jobs?per_page={PAGE}",
1013                self.repo
1014            ))?;
1015            for entry in listed(&listing, "jobs", &format!("workflow run {id}"))? {
1016                jobs.push(job(entry, cr, head.as_ref())?);
1017            }
1018        }
1019        Ok(jobs)
1020    }
1021
1022    /// The names of the checks the repository's rulesets say block a merge into the
1023    /// change request's base.
1024    ///
1025    /// The second question `gh pr checks --required` answers and a credential that
1026    /// cannot read check runs cannot ask, because that command reads the same
1027    /// rollup. This is what such a credential *can* read — and it is narrower, in a
1028    /// direction that is safe rather than quiet: it reports rulesets and not classic
1029    /// branch protection, so a repository protected the classic way answers "nothing
1030    /// blocks" and the publication path then waits for a required check that never
1031    /// arrives and stops. Under-reporting what blocks holds a merge; over-reporting
1032    /// what has passed lets one through, and only one of those is recoverable.
1033    fn ruled_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
1034        self.required_on(&cr.base, cr.url.as_ref())
1035    }
1036
1037    /// The names of the checks the repository's rulesets require before a merge into
1038    /// `base` — the same read as [`GitHub::ruled_checks`], addressed to a branch
1039    /// rather than to a change request targeting one, and what
1040    /// [`RemoteHost::required_checks_on`] answers with. It is the rulesets' answer
1041    /// and carries the rulesets' limit: a repository protected the classic way names
1042    /// nothing here.
1043    ///
1044    fn required_on(&self, base: &str, about: &str) -> Result<BTreeSet<String>> {
1045        addressable_branch(base, "the base branch")?;
1046        let value = self.api(&format!(
1047            "repos/{}/rules/branches/{}",
1048            self.repo,
1049            path_segment(base)
1050        ))?;
1051        let rules = value.as_array().ok_or_else(|| {
1052            invalid(format!(
1053                "the rules on {about}'s base branch {base:?} came back as something that is \
1054                 not a list of them, so which of its checks block the merge cannot be read \
1055                 from it: {value}"
1056            ))
1057        })?;
1058        let mut names = BTreeSet::new();
1059        for rule in rules {
1060            if rule.get("type").and_then(|value| value.as_str()) != Some("required_status_checks") {
1061                continue;
1062            }
1063            // A rule that says it requires status checks and then will not say
1064            // which is the one shape that must not be read as "none": that is the
1065            // difference between a merge that was gated and one that only looked
1066            // like it.
1067            let required = rule
1068                .get("parameters")
1069                .and_then(|parameters| parameters.get("required_status_checks"))
1070                .and_then(|value| value.as_array())
1071                .ok_or_else(|| unsaid_about(about, &rule.to_string()))?;
1072            for entry in required {
1073                let name = entry
1074                    .get("context")
1075                    .and_then(|value| value.as_str())
1076                    .filter(|value| !value.is_empty())
1077                    .ok_or_else(|| unsaid_about(about, &entry.to_string()))?;
1078                names.insert(name.to_owned());
1079            }
1080        }
1081        Ok(names)
1082    }
1083
1084    /// The contexts classic branch protection requires on `base`, or why the host
1085    /// would not say.
1086    ///
1087    /// Three answers, told apart by what `gh` wrote. A protected branch answers with
1088    /// its contexts; a branch with no classic protection answers `Branch not
1089    /// protected`, a 404 that *is* an answer — the source was consulted and names
1090    /// nothing; and anything else is the source not consulted, which the inner `Err`
1091    /// carries so the caller records it beside what the rulesets did say rather than
1092    /// losing that too. The outer `Err` is `gh` itself failing to run.
1093    fn classic_protection(
1094        &self,
1095        base: &str,
1096    ) -> Result<std::result::Result<BTreeSet<String>, String>> {
1097        addressable_branch(base, "the base branch")?;
1098        let path = format!(
1099            "repos/{}/branches/{}/protection/required_status_checks",
1100            self.repo,
1101            path_segment(base)
1102        );
1103        let answer = gh::attempt(&["api", &path])?;
1104        if answer.code != Some(0) {
1105            if answer.stderr.contains(NOT_PROTECTED) {
1106                return Ok(Ok(BTreeSet::new()));
1107            }
1108            return Ok(Err(answer.detail()));
1109        }
1110        let value = gh::json(&answer.stdout)?;
1111        let contexts = value
1112            .get("contexts")
1113            .and_then(|value| value.as_array())
1114            .ok_or_else(|| {
1115                invalid(format!(
1116                    "the classic branch protection on {}'s {base:?} came back without its \
1117                     contexts, so which checks it requires cannot be read from it: {value}",
1118                    self.repo
1119                ))
1120            })?;
1121        let mut names = BTreeSet::new();
1122        for context in contexts {
1123            let name = context
1124                .as_str()
1125                .filter(|value| !value.is_empty())
1126                .ok_or_else(|| unsaid_about(&self.repo, &context.to_string()))?;
1127            names.insert(name.to_owned());
1128        }
1129        Ok(Ok(names))
1130    }
1131
1132    /// The log of the Actions job one check ran in, addressed by the job's id.
1133    ///
1134    /// The id comes from the same listing the checks did, so the name matched here
1135    /// is the name reported there — the two cannot drift apart the way a check's
1136    /// name and a details URL parsed out of a second command can.
1137    fn actions_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
1138        let job = self
1139            .actions_jobs(cr)?
1140            .into_iter()
1141            .find(|job| job.name == name)
1142            .ok_or_else(|| {
1143                invalid(format!(
1144                    "GitHub Actions reports no job named {name:?} on the head commit of {}",
1145                    cr.url
1146                ))
1147            })?;
1148        gh::invoke_content(&[
1149            "api",
1150            &format!("repos/{}/actions/jobs/{}/logs", self.repo, job.id),
1151        ])
1152    }
1153
1154    fn api(&self, path: &str) -> Result<serde_json::Value> {
1155        gh::json(&gh::invoke(&["api", path])?)
1156    }
1157
1158    /// One change request as `gh` describes it, in the fields the caller reads and
1159    /// no others.
1160    ///
1161    /// The field list is the caller's, not a constant, because `gh pr view` fails
1162    /// *whole* when GitHub refuses one field of the several it was asked for — and
1163    /// the fields differ in what a credential has to be allowed to see.
1164    /// `statusCheckRollup` resolves the change request's check runs, which a
1165    /// fine-grained token may not read without the repository's Checks permission,
1166    /// while `state`, `headRefOid`, and `mergeCommit` need no more than the read
1167    /// access that found the change request at all. This build asked for all of them
1168    /// at every call, so a token that could open and merge a change request was
1169    /// refused at both over a field neither reads — and only from the moment a check
1170    /// first appeared on it, so the same credential merged a young change request and
1171    /// failed on an older one. Ask for what you read.
1172    fn view(&self, id: &str, fields: &str) -> Result<serde_json::Value> {
1173        addressable(id, "change request id")?;
1174        let raw = gh::invoke(&["pr", "view", id, "--repo", &self.repo, "--json", fields])?;
1175        gh::json(&raw)
1176    }
1177}
1178
1179/// One value bound for `gh`'s argument vector, checked before it gets there.
1180///
1181/// Every string in these methods arrives from outside — off a caller embedding this
1182/// crate, or out of the host's own answer — and each becomes a positional or an
1183/// option's value. `gh` reads a leading `-` as an option of its own and an empty
1184/// string as a present-but-blank value, so a name shaped like either is refused
1185/// here rather than silently addressing something other than what it names.
1186///
1187/// Those two and no others: the vector is handed to `gh` as a vector, so no shell
1188/// splits an argument this build wrote as one. Whitespace was refused here too, and
1189/// [`matchable`] records what that cost — a value with a space in it reaches `gh`
1190/// exactly as it was written, so it is nothing this boundary has to guard against.
1191fn addressable(value: &str, what: &str) -> Result<()> {
1192    if value.is_empty() || value.starts_with('-') {
1193        return Err(invalid(format!(
1194            "{what} {value:?} cannot address anything on the host: it must be non-empty and must \
1195             not begin with '-'"
1196        )));
1197    }
1198    Ok(())
1199}
1200
1201/// A check's name, checked before it is matched against the ones the host reports.
1202///
1203/// Deliberately not [`addressable`], because a check's name never becomes an
1204/// argument to `gh`: [`GitHub::job_log`] matches it against the `name` field of the
1205/// JSON `gh pr checks` returns, and [`GitHub::actions_log`] against the job names
1206/// the Actions API lists — the job's *id* is what either one then asks for a log by.
1207/// So `gh`'s argument grammar has no say over this value, and the shapes that
1208/// grammar refuses are ordinary job names here. Every GitHub matrix job is named
1209/// with whitespace — `check (macos-latest)`, `test-os (windows-latest)` — and
1210/// refusing it left every matrix check on every change request this crate published
1211/// recorded with no log at all.
1212///
1213/// What is refused is a name that can match nothing the host reports: the host names
1214/// every check it answers about, so an empty name is a value a caller made up rather
1215/// than a check anything has.
1216fn matchable(name: &str, cr: &ChangeRequest) -> Result<()> {
1217    if name.is_empty() {
1218        return Err(invalid(format!(
1219            "onevcs will not ask for the log of a check with no name on {}: every check the host \
1220             reports is named, so an empty name matches none of them. This build refused the \
1221             request; the host was not asked.",
1222            cr.url
1223        )));
1224    }
1225    Ok(())
1226}
1227
1228/// One value bound for a URL path segment, percent-encoded.
1229///
1230/// A branch name is the only one of these, and git allows characters in one that a
1231/// path does not — a `/` above all, which every stacked branch carries and which
1232/// would otherwise address a resource nobody asked for. Encoded rather than
1233/// refused: `feature/thing` is a branch a repository really has.
1234fn path_segment(value: &str) -> String {
1235    let mut encoded = String::with_capacity(value.len());
1236    for byte in value.bytes() {
1237        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1238            encoded.push(char::from(byte));
1239        } else {
1240            encoded.push_str(&format!("%{byte:02X}"));
1241        }
1242    }
1243    encoded
1244}
1245
1246/// A commit hash bound for a query string, checked before it gets there.
1247///
1248/// It arrives off a host response and goes on to be one parameter of several, so a
1249/// value carrying anything but hex digits could address a query nobody wrote.
1250fn commit(sha: &Sha) -> Result<&str> {
1251    if !is_commit_hash(&sha.0) {
1252        return Err(invalid(format!(
1253            "{:?} is not a commit hash, so the checks reported against it cannot be asked for",
1254            sha.0
1255        )));
1256    }
1257    Ok(&sha.0)
1258}
1259
1260/// Whether text the host handed over is the shape of a commit hash.
1261///
1262/// The one shape rule, so the two boundaries that read a commit off this host agree
1263/// about what one is: the commit an Actions query is built from ([`commit`]) and the
1264/// commit a host says it attached a check to ([`reported_sha`]). Shared as the rule
1265/// rather than as the refusal because each of those refusals costs something
1266/// different and says so.
1267fn is_commit_hash(sha: &str) -> bool {
1268    !sha.is_empty() && sha.chars().all(|c| c.is_ascii_hexdigit())
1269}
1270
1271/// One workflow job, as the Actions API reports it.
1272struct Job {
1273    /// What `gh api .../actions/jobs/<id>/logs` addresses its log by.
1274    id: u64,
1275    /// The job's name, which is the name GitHub posts its check run under.
1276    name: String,
1277    /// Where it is, in the host's own vocabulary.
1278    status: String,
1279    /// How it ended, once it has.
1280    conclusion: Option<String>,
1281    /// The commit its workflow run was reported against, off that run's own record.
1282    head: Option<Sha>,
1283    /// Where a human reads this job on the host.
1284    url: Option<Url>,
1285    /// When the host says this job started.
1286    started_at: Option<String>,
1287}
1288
1289/// One job of an Actions listing, required to say what it is.
1290///
1291/// Defaulting a missing field is what would let a job that is still running be read
1292/// as a green check, which is the shape that must never be inferred — the same rule
1293/// [`check`] below is held to, for the same reason.
1294fn job(entry: &serde_json::Value, cr: &ChangeRequest, head: Option<&Sha>) -> Result<Job> {
1295    let field = |name: &str| -> Result<&str> {
1296        entry
1297            .get(name)
1298            .and_then(|value| value.as_str())
1299            .filter(|value| !value.is_empty())
1300            .ok_or_else(|| {
1301                invalid(format!(
1302                    "the Actions API returned a job on {} with no {name}: {entry}",
1303                    cr.url
1304                ))
1305            })
1306    };
1307    Ok(Job {
1308        id: entry
1309            .get("id")
1310            .and_then(serde_json::Value::as_u64)
1311            .ok_or_else(|| {
1312                invalid(format!(
1313                    "the Actions API returned a job on {} with no id, so its log cannot be asked \
1314                     for: {entry}",
1315                    cr.url
1316                ))
1317            })?,
1318        name: field("name")?.to_owned(),
1319        status: field("status")?.to_ascii_lowercase(),
1320        // Genuinely absent while a job is still running.
1321        conclusion: entry
1322            .get("conclusion")
1323            .and_then(|value| value.as_str())
1324            .filter(|value| !value.is_empty())
1325            .map(str::to_ascii_lowercase),
1326        head: head.cloned(),
1327        url: reported_url(entry, "html_url"),
1328        started_at: reported_start(entry, "started_at"),
1329    })
1330}
1331
1332/// The commit a host response names, off whichever field that response spells it
1333/// in, or `None` where it named none.
1334///
1335/// Lenient about *absence* where [`head_sha`] refuses, and the difference is what
1336/// each answer is for: a change request with no head is one this build cannot go on
1337/// to ask the Actions API about at all, while a check whose source would not say
1338/// which commit it is about is still a check, and reading it is still the answer.
1339/// Inventing one is the thing that must not happen — which is why an absent field
1340/// reads `None` rather than falling back to the head the caller already holds.
1341///
1342/// Strict about *shape* exactly where it is lenient about absence, and for the same
1343/// reason. This is host-supplied text arriving at a trust boundary, and it is held
1344/// to [`is_commit_hash`] like every other commit read off this host. Passed through
1345/// unchecked it would be worse than a refusal rather than more forgiving than one:
1346/// what a publication does with this value is compare it to the commit it pushed,
1347/// so anything that is not a commit hash matches nothing and reads as "the host has
1348/// said nothing about your commit yet" — silence — for as long as the watch runs,
1349/// and then times out naming a commit the host never had trouble with. A malformed
1350/// answer must not be able to wear the one shape this path exists to keep distinct
1351/// from an answer.
1352fn reported_sha(value: &serde_json::Value, field: &str) -> Result<Option<Sha>> {
1353    let Some(reported) = value
1354        .get(field)
1355        .and_then(|value| value.as_str())
1356        .filter(|value| !value.is_empty())
1357    else {
1358        return Ok(None);
1359    };
1360    if !is_commit_hash(reported) {
1361        return Err(invalid(format!(
1362            "gh reported {reported:?} as the {field} of a check, and that is not a commit hash, \
1363             so which commit the check is attached to cannot be decided"
1364        )));
1365    }
1366    Ok(Some(Sha(reported.to_owned())))
1367}
1368
1369/// Where the host says something is, off whichever field that response spells it
1370/// in, or `None` where it named nowhere this build can point a person at.
1371///
1372/// A URL that will not parse is `None` rather than a refusal: this is the address a
1373/// refusal hands over so somebody can go and look, and failing a publication over a
1374/// link would put the check's verdict behind a formatting complaint.
1375fn reported_url(entry: &serde_json::Value, field: &str) -> Option<Url> {
1376    entry
1377        .get(field)
1378        .and_then(|value| value.as_str())
1379        .filter(|value| !value.is_empty())
1380        .and_then(|value| Url::parse(value).ok())
1381}
1382
1383/// The entries of one Actions listing, refused unless the page carried them all.
1384///
1385/// These endpoints page, and a page that held entries back is a partial answer
1386/// about what ran — read as the whole of it, a check nobody saw is a check nobody
1387/// waited for. `total_count` is how the answer says how many there are, so the two
1388/// are compared rather than the list being trusted to be complete.
1389fn listed<'a>(
1390    value: &'a serde_json::Value,
1391    field: &str,
1392    what: &str,
1393) -> Result<&'a Vec<serde_json::Value>> {
1394    let entries = value
1395        .get(field)
1396        .and_then(|value| value.as_array())
1397        .ok_or_else(|| {
1398            invalid(format!(
1399                "the Actions API answered about {what} with something that lists no {field}: \
1400                 {value}"
1401            ))
1402        })?;
1403    let total = value
1404        .get("total_count")
1405        .and_then(serde_json::Value::as_u64)
1406        .ok_or_else(|| {
1407            invalid(format!(
1408                "the Actions API did not say how many {field} there are on {what}: {value}"
1409            ))
1410        })?;
1411    if total > entries.len() as u64 {
1412        return Err(invalid(format!(
1413            "the Actions API reports {total} {field} on {what} and returned {}, so this build has \
1414             not been shown all of them",
1415            entries.len()
1416        )));
1417    }
1418    Ok(entries)
1419}
1420
1421/// The refusal for a credential that can read none of the host's check sources.
1422///
1423/// Both refusals, verbatim, and the permission that answers them — because the
1424/// thing an operator cannot tell from GitHub's own words is that one of these is
1425/// not a scope to widen: a fine-grained personal access token has no `Checks`
1426/// permission to grant, so the rollup is out of reach for that credential class
1427/// however it is scoped, and `Actions: Read` is what makes the other source work.
1428fn unreadable(repo: &str, cr: &ChangeRequest, rollup: &Error, actions: &Error) -> Error {
1429    invalid(format!(
1430        "neither of GitHub's check sources could be read for {}, so what its checks say is \
1431         unknown rather than empty. Its check rollup answered: {rollup}. Its Actions API \
1432         answered: {actions}. Grant this credential `Actions: Read` on {repo} to read the \
1433         repository's workflow checks — a fine-grained personal access token cannot read the \
1434         rollup at all, whatever it is scoped to, because GitHub offers no Checks permission for \
1435         one.",
1436        cr.url
1437    ))
1438}
1439
1440/// A branch name a caller supplied, checked by the parser that decides branch names.
1441fn addressable_branch(value: &str, what: &str) -> Result<()> {
1442    if !git::is_valid_branch_name(value) {
1443        return Err(invalid(format!(
1444            "{what} {value:?} is a name git would not accept"
1445        )));
1446    }
1447    Ok(())
1448}
1449
1450impl RemoteHost for GitHub {
1451    fn authenticated_user(&self) -> Result<String> {
1452        let login = gh::invoke(&["api", "user", "--jq", ".login"])?
1453            .trim()
1454            .to_owned();
1455        if login.is_empty() {
1456            return Err(Error::Invalid {
1457                reason: "gh reported no authenticated user".to_owned(),
1458            });
1459        }
1460        Ok(login)
1461    }
1462
1463    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
1464        addressable_branch(&req.head, "the head branch")?;
1465        addressable_branch(&req.base, "the base branch")?;
1466        // The publication rule the crate states, applied rather than restated —
1467        // and applied here as well as where a publication takes a request in, because
1468        // [`ChangeSpec`] is public and this trait is reachable directly. A reason no
1469        // publication could have carried must not open a change request the record
1470        // then cannot say anything readable about.
1471        if let Some(reason) = &req.draft {
1472            reason.checked()?;
1473        }
1474        let body = req.body.unwrap_or_default();
1475        let mut args = vec![
1476            "pr", "create", "--repo", &self.repo, "--head", &req.head, "--base", &req.base,
1477            "--title", &req.title, "--body", &body,
1478        ];
1479        // The whole of what the reason does at the host: it opens as a draft. Nothing
1480        // of the reason itself is written there — see `DraftReason`.
1481        if req.draft.is_some() || req.draft_awaiting_checks {
1482            args.push("--draft");
1483        }
1484        let raw = gh::invoke(&args)?;
1485        let url = raw
1486            .lines()
1487            .map(str::trim)
1488            .rfind(|line| line.starts_with("http"))
1489            .ok_or_else(|| invalid(format!("gh pr create printed no URL: {raw:?}")))?;
1490        let parsed = Url::parse(url)
1491            .map_err(|e| invalid(format!("gh pr create printed {url:?}, not a URL: {e}")))?;
1492        // The host numbers its change requests, and the number is the last segment
1493        // of the URL it printed. Anything else in that position is `gh` having
1494        // printed something other than a change request's URL, which is not an
1495        // identifier to go on addressing it by.
1496        let id = parsed
1497            .path_segments()
1498            .and_then(|mut segments| segments.next_back())
1499            .filter(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
1500            .ok_or_else(|| {
1501                invalid(format!(
1502                    "gh pr create printed {url:?}, which names no change"
1503                ))
1504            })?
1505            .to_owned();
1506        Ok(ChangeRequest {
1507            // The commit the new change request's checks will be reported against,
1508            // which `gh pr create` does not print.
1509            head_sha: head_sha(&self.view(&id, "headRefOid")?)?,
1510            id: ChangeId(id),
1511            url: parsed,
1512            base: req.base,
1513        })
1514    }
1515
1516    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
1517        addressable_branch(head, "the head branch")?;
1518        addressable_branch(base, "the base branch")?;
1519        let raw = gh::invoke(&[
1520            "pr",
1521            "list",
1522            "--repo",
1523            &self.repo,
1524            "--head",
1525            head,
1526            "--base",
1527            base,
1528            "--state",
1529            "open",
1530            "--json",
1531            "number,url,state,headRefOid",
1532        ])?;
1533        let value = gh::json(&raw)?;
1534        let items = value
1535            .as_array()
1536            .ok_or_else(|| invalid(format!("gh pr list returned {raw:?}, not a list")))?;
1537        let mut changes = Vec::new();
1538        for item in items {
1539            let url = item.get("url").and_then(|v| v.as_str()).unwrap_or_default();
1540            let parsed = Url::parse(url)
1541                .map_err(|e| invalid(format!("gh pr list returned {url:?}, not a URL: {e}")))?;
1542            let number = item
1543                .get("number")
1544                .and_then(|value| value.as_u64())
1545                .ok_or_else(|| invalid(format!("gh pr list returned no number: {raw:?}")))?
1546                .to_string();
1547            changes.push(ChangeRequest {
1548                id: ChangeId(number),
1549                url: parsed,
1550                head_sha: head_sha(item)?,
1551                base: base.to_owned(),
1552            });
1553        }
1554        Ok(changes)
1555    }
1556
1557    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
1558        let rollup = |checks| ChangeChecks {
1559            checks,
1560            sources: [CheckSource::StatusChecks].into_iter().collect(),
1561        };
1562        match consult()? {
1563            Consult::StatusChecks => self.rollup_checks(cr).map(rollup),
1564            Consult::Actions => self.actions_checks(cr),
1565            // The rollup first, because it is the complete answer and this build
1566            // cannot know what the credential is until it is refused. A refusal is
1567            // never read as "no checks": the fallback either produces an answer of
1568            // its own or both refusals are reported together.
1569            Consult::Either => match self.rollup_checks(cr) {
1570                Ok(checks) => Ok(rollup(checks)),
1571                Err(refused) if check_rollup_refused_for_pat(&refused) => self
1572                    .actions_checks(cr)
1573                    .map_err(|actions| unreadable(&self.repo, cr, &refused, &actions)),
1574                Err(refused) => Err(refused),
1575            },
1576        }
1577    }
1578
1579    /// Store one check's log as an artifact, or refuse.
1580    ///
1581    /// A fetch that did not produce the log is an error and no artifact: an
1582    /// artifact reads as what the check printed, so one holding the reason there is
1583    /// none is a lie in the only record a consumer has. A caller that must not fail
1584    /// over a missing log declines to fail over this error, as [`crate::publish`]
1585    /// does.
1586    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
1587        // The two are told apart, because they are two different things to whoever
1588        // reads the refusal: a name this build would not ask about is this build's
1589        // own doing and says so, while a host that would not produce the log is the
1590        // host's and quotes what it said. They used to arrive as one message
1591        // attributing both to the host, which is how a refusal this crate made read
1592        // as GitHub keeping its logs — and why it went unnoticed for as long as it
1593        // did. The kind stays `Invalid` for both, because the failure vocabulary is
1594        // fixed across the libraries that route on it; the message is where the
1595        // actor is named.
1596        matchable(&check.name, cr)?;
1597        let log = self.log_of(cr, &check.name).map_err(|error| {
1598            invalid(format!(
1599                "the host could not produce a log for check {:?} on {}: {error}",
1600                check.name, cr.url
1601            ))
1602        })?;
1603        Ok(ArtifactId(stream::store_artifact("log", &log)?.id))
1604    }
1605
1606    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
1607        match policy {
1608            MergePolicy::LocalDirect | MergePolicy::ChangeOpen => Ok(MergeOutcome::Open),
1609            MergePolicy::ChangeAuto => {
1610                addressable(&cr.id.0, "change request id")?;
1611                gh::invoke(&[
1612                    "pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash", "--auto",
1613                ])?;
1614                let view = self.view(&cr.id.0, MERGE_FIELDS)?;
1615                Ok(match merged_sha(&view, cr)? {
1616                    Some(sha) => MergeOutcome::Merged(sha),
1617                    None => MergeOutcome::Queued,
1618                })
1619            }
1620            MergePolicy::ChangeDirect => {
1621                addressable(&cr.id.0, "change request id")?;
1622                gh::invoke(&["pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash"])?;
1623                let view = self.view(&cr.id.0, MERGE_FIELDS)?;
1624                match merged_sha(&view, cr)? {
1625                    Some(sha) => Ok(MergeOutcome::Merged(sha)),
1626                    None => Err(Error::GateFailed {
1627                        reason: format!(
1628                            "the host accepted the merge of {} but reports it unmerged",
1629                            cr.url
1630                        ),
1631                    }),
1632                }
1633            }
1634        }
1635    }
1636
1637    fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
1638        addressable(&cr.id.0, "change request id")?;
1639        gh::invoke(&["pr", "ready", &cr.id.0, "--repo", &self.repo]).map(|_| ())
1640    }
1641
1642    /// One `gh pr edit`, handing the body over as a file.
1643    ///
1644    /// A file rather than `--body`, because a body is prose of unbounded size and an
1645    /// argument vector is not where multi-line Markdown belongs: the same reason
1646    /// `onevcs publish` takes `--body-file`. The file is written under a scratch
1647    /// directory that goes when this returns, and `gh` reads it before then.
1648    fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
1649        addressable(&cr.id.0, "change request id")?;
1650        let scratch = tempfile::tempdir().map_err(|failure| {
1651            invalid(format!(
1652                "could not create a scratch directory to hand the body of {} to gh: {failure}",
1653                cr.url
1654            ))
1655        })?;
1656        let file = scratch.path().join("body.md");
1657        std::fs::write(&file, body).map_err(crate::error::at(
1658            "write the change request's body to",
1659            &file,
1660        ))?;
1661        let file = file.to_string_lossy().into_owned();
1662        let mut args = vec![
1663            "pr",
1664            "edit",
1665            &cr.id.0,
1666            "--repo",
1667            &self.repo,
1668            "--body-file",
1669            &file,
1670        ];
1671        if let Some(title) = title {
1672            args.extend(["--title", title]);
1673        }
1674        gh::invoke(&args).map(|_| ())
1675    }
1676
1677    /// One `gh pr view`, reading exactly the two fields a description is.
1678    ///
1679    /// A response missing either is refused rather than read as empty: "the change
1680    /// request has no body" and "the host would not say what the body is" are
1681    /// different facts, and a caller finishing a description off what it reads here
1682    /// would otherwise start from a blank the host never answered.
1683    fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
1684        addressable(&cr.id.0, "change request id")?;
1685        let view = self.view(&cr.id.0, "title,body")?;
1686        let read = |name: &str| {
1687            view.get(name)
1688                .and_then(serde_json::Value::as_str)
1689                .map(str::to_owned)
1690                .ok_or_else(|| {
1691                    invalid(format!(
1692                        "gh pr view answered about {} without its {name}, so what the change \
1693                         request says cannot be read",
1694                        cr.url
1695                    ))
1696                })
1697        };
1698        Ok(Description {
1699            title: read("title")?,
1700            body: read("body")?,
1701        })
1702    }
1703
1704    /// One `gh pr view`, reading the one field the host decides a draft by.
1705    ///
1706    /// A response that does not carry it is refused rather than read as "not a
1707    /// draft": what this decides is whether a change may be asked to merge, and
1708    /// "could not look" reported as "nothing is holding it" is the one answer that
1709    /// lands work somebody held back.
1710    fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
1711        addressable(&cr.id.0, "change request id")?;
1712        let view = self.view(&cr.id.0, "isDraft")?;
1713        view.get("isDraft")
1714            .and_then(serde_json::Value::as_bool)
1715            .ok_or_else(|| {
1716                invalid(format!(
1717                    "gh pr view answered about {} without saying whether it is a draft, so \
1718                     whether the host is holding it cannot be read",
1719                    cr.url
1720                ))
1721            })
1722    }
1723
1724    /// One `gh pr view`, reading exactly the two fields a merge is decided from —
1725    /// the same read `merge` makes, through the same parser, so watching a change
1726    /// request and merging one cannot come to disagree about what merged means.
1727    fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
1728        addressable(&cr.id.0, "change request id")?;
1729        merged_sha(&self.view(&cr.id.0, MERGE_FIELDS)?, cr)
1730    }
1731
1732    /// One `gh pr view`, reading exactly the two fields GitHub decides a merge's
1733    /// mergeability by.
1734    ///
1735    /// `mergeable` is the answer — `MERGEABLE`, `CONFLICTING`, or `UNKNOWN` while
1736    /// GitHub has not computed it — and `mergeStateStatus` is quoted beside it, since
1737    /// `DIRTY` is how GitHub spells the same conflict there. A response that carries
1738    /// no `mergeable`, or a value GitHub has never answered, is refused rather than
1739    /// read as either: a conflict nobody reported ends a publication, and a mergeable
1740    /// change nobody reported hides one.
1741    fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
1742        addressable(&cr.id.0, "change request id")?;
1743        let view = self.view(&cr.id.0, MERGEABILITY_FIELDS)?;
1744        let mergeable = view
1745            .get("mergeable")
1746            .and_then(serde_json::Value::as_str)
1747            .ok_or_else(|| {
1748                invalid(format!(
1749                    "gh pr view answered about {} without saying whether it can merge: {view}",
1750                    cr.url
1751                ))
1752            })?;
1753        let status = view
1754            .get("mergeStateStatus")
1755            .and_then(serde_json::Value::as_str)
1756            .unwrap_or("unreported");
1757        match mergeable.to_ascii_uppercase().as_str() {
1758            "MERGEABLE" => Ok(Mergeability::Mergeable),
1759            "CONFLICTING" => Ok(Mergeability::Conflicting {
1760                reported: format!("mergeable: {mergeable}, mergeStateStatus: {status}"),
1761            }),
1762            "UNKNOWN" => Ok(Mergeability::Unknown),
1763            _ => Err(invalid(format!(
1764                "gh pr view reports {} as mergeable {mergeable:?}, which is none of \
1765                 MERGEABLE, CONFLICTING or UNKNOWN, so whether it can merge cannot be read",
1766                cr.url
1767            ))),
1768        }
1769    }
1770
1771    fn required_checks_on(&self, base: &str) -> Result<RequiredChecks> {
1772        // The rulesets first, and their refusal is the whole read's: every
1773        // credential that can see the repository can read them, so one that cannot
1774        // has not answered about anything. Classic protection is asked second and
1775        // its refusal is recorded rather than raised, because the credential GitHub
1776        // steers people toward is refused it on every repository, and the rulesets'
1777        // answer is still worth having — marked as the part it is.
1778        let checks = self.required_on(base, &self.repo)?;
1779        let mut answer = RequiredChecks {
1780            checks,
1781            unconsulted: BTreeMap::new(),
1782        };
1783        match self.classic_protection(base)? {
1784            Ok(contexts) => answer.checks.extend(contexts),
1785            Err(refusal) => {
1786                answer
1787                    .unconsulted
1788                    .insert(ProtectionSource::BranchProtection, refusal);
1789            }
1790        }
1791        Ok(answer)
1792    }
1793}
1794
1795/// The refusal for a host that will not say which of its checks block the merge.
1796///
1797/// Inferring it is the one shape that must never be guessed: it is the difference
1798/// between a merge that was gated and one that only looked like it. What changed
1799/// with the real backend is *where* the question is asked, not that it may be
1800/// skipped — a host that answers `gh pr checks --required` with something other
1801/// than the checks it requires is refused here rather than read as requiring none.
1802fn unsaid(url: &Url, detail: &str) -> Error {
1803    invalid(format!(
1804        "gh pr checks --required answered about {url} with {detail}, so a check it reported does \
1805         not say whether it blocks the merge"
1806    ))
1807}
1808
1809/// The same refusal from the rulesets read, which is addressed to a base branch and
1810/// names whatever asked about it — a change request's URL, or an identity.
1811fn unsaid_about(about: &str, detail: &str) -> Error {
1812    invalid(format!(
1813        "the repository's rulesets answered about {about} with {detail}, so a check they \
1814         reported does not say whether it blocks the merge"
1815    ))
1816}
1817
1818/// The refusal for a host that has no check on the head to report yet.
1819///
1820/// Separate from [`unsaid`] because the next move differs: this one is a clock, and
1821/// whoever reads it waits rather than re-runs.
1822fn unregistered(url: &Url, said: &str) -> Error {
1823    invalid(format!(
1824        "the host reports no check at all yet on the head of {url} ({said}), which is not the \
1825         same as a repository that declares none required — it is what a head pushed moments ago \
1826         looks like — so whether anything blocks the merge cannot be read from it"
1827    ))
1828}
1829
1830/// One entry of the host's check rollup, required to say what it is.
1831///
1832/// Defaulting a missing field here is what would let a host that answered
1833/// partially be read as a green check — the one shape that must never be inferred.
1834/// Whether the check *blocks* is not in this entry at all and never was: it comes
1835/// from [`GitHub::required_checks`], which refuses a host that will not say.
1836fn check(
1837    entry: &serde_json::Value,
1838    cr: &ChangeRequest,
1839    required: &BTreeSet<String>,
1840    head: Option<&Sha>,
1841) -> Result<Check> {
1842    let field = |name: &str| -> Result<&str> {
1843        entry
1844            .get(name)
1845            .and_then(|value| value.as_str())
1846            .filter(|value| !value.is_empty())
1847            .ok_or_else(|| {
1848                invalid(format!(
1849                    "gh pr view returned a check on {} with no {name}: {entry}",
1850                    cr.url
1851                ))
1852            })
1853    };
1854    let name = field("name").or_else(|_| field("context"))?.to_owned();
1855    let blocks = required.contains(&name);
1856    Ok(Check {
1857        name,
1858        status: field("status")?.to_ascii_lowercase(),
1859        // Genuinely absent while a check is still running, which is the one thing
1860        // the host cannot yet know.
1861        conclusion: entry
1862            .get("conclusion")
1863            .and_then(|value| value.as_str())
1864            .filter(|value| !value.is_empty())
1865            .map(str::to_ascii_lowercase),
1866        required: blocks,
1867        head: head.cloned(),
1868        // A check run's own address, and only that spelling. A commit status
1869        // spells it `targetUrl` — and never reaches here: it carries `state` where
1870        // this reads `status`, so `field("status")` above refuses the whole answer
1871        // before an address is looked for. Reading the second spelling would be a
1872        // branch nothing can drive, on a path that already cannot be taken.
1873        url: reported_url(entry, "detailsUrl"),
1874        started_at: reported_start(entry, "startedAt"),
1875    })
1876}
1877
1878/// When a host response says a run started, or `None` where it did not say.
1879///
1880/// GitHub answers a run that is queued and has not started with the zero time rather
1881/// than with nothing, and that is read as nothing: a start every queued run shares is
1882/// no run's identity.
1883fn reported_start(entry: &serde_json::Value, field: &str) -> Option<String> {
1884    entry
1885        .get(field)
1886        .and_then(|value| value.as_str())
1887        .filter(|value| !value.is_empty() && !value.starts_with("0001-01-01"))
1888        .map(str::to_owned)
1889}
1890
1891/// What [`merged_sha`] reads, which is what a merge asks `gh pr view` for.
1892const MERGE_FIELDS: &str = "state,mergeCommit";
1893
1894/// What [`GitHub`]'s `mergeability` reads.
1895const MERGEABILITY_FIELDS: &str = "mergeable,mergeStateStatus";
1896
1897/// The commit a merged change request landed as, or `None` while it is still open.
1898///
1899/// A host reporting `MERGED` without naming the commit is answering wrongly rather
1900/// than answering "not yet": the commit is the whole evidence that the change
1901/// reached its base, and reporting a merge with no SHA is the one thing that must
1902/// not be passed through.
1903fn merged_sha(view: &serde_json::Value, cr: &ChangeRequest) -> Result<Option<Sha>> {
1904    let state = view
1905        .get("state")
1906        .and_then(|value| value.as_str())
1907        .ok_or_else(|| invalid(format!("gh pr view returned no state for {}", cr.url)))?;
1908    if !state.eq_ignore_ascii_case("merged") {
1909        return Ok(None);
1910    }
1911    view.get("mergeCommit")
1912        .and_then(|commit| commit.get("oid"))
1913        .and_then(|value| value.as_str())
1914        .filter(|value| !value.is_empty())
1915        .map(|sha| Some(Sha(sha.to_owned())))
1916        .ok_or_else(|| {
1917            invalid(format!(
1918                "gh pr view reports {} merged without naming the commit it merged as",
1919                cr.url
1920            ))
1921        })
1922}
1923
1924/// The commit a change request's checks are reported against.
1925fn head_sha(view: &serde_json::Value) -> Result<Sha> {
1926    view.get("headRefOid")
1927        .and_then(|value| value.as_str())
1928        .filter(|value| !value.is_empty())
1929        .map(|sha| Sha(sha.to_owned()))
1930        .ok_or_else(|| invalid(format!("gh returned a change request with no head: {view}")))
1931}