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