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::BTreeSet;
7
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::error::{invalid, Error, Result};
12use crate::event::ArtifactId;
13use crate::rules::MergePolicy;
14use crate::{gh, git, stream};
15
16/// What `gh pr checks` exits with when a check it has just reported has not
17/// settled. The rollup it printed is still the answer, so this status is read
18/// alongside `0` rather than as a failure.
19const CHECKS_PENDING: i32 = 8;
20
21/// How `gh pr checks --required` opens its stderr when the repository declares no
22/// required check at all — a repository with no branch protection genuinely has
23/// none. The rest of that line names the branch, so only the prefix is fixed.
24const NO_REQUIRED_CHECKS: &str = "no required checks reported on the ";
25
26/// Whether a `gh pr checks` answer is one to read.
27fn usable(answer: &gh::Answer) -> bool {
28    matches!(answer.code, Some(0) | Some(CHECKS_PENDING))
29}
30
31/// Whether a failing `gh pr checks --required` failed by *answering* "none".
32///
33/// The whole shape, not a substring anywhere in the output: `gh` exits `1`, writes
34/// nothing to stdout, and opens stderr with [`NO_REQUIRED_CHECKS`]. Measured against
35/// real GitHub — a repository with no branch protection answers exactly that. It is
36/// held to all three because this is the one place a *failed* `gh` call is read as a
37/// meaningful answer, and a looser test would swallow an unrelated failure whose
38/// message happened to contain the phrase, reporting "nothing blocks the merge"
39/// about a host that never said so.
40fn no_required_checks(answer: &gh::Answer) -> bool {
41    answer.code == Some(1)
42        && answer.stdout.trim().is_empty()
43        && answer.stderr.trim_start().starts_with(NO_REQUIRED_CHECKS)
44}
45
46/// Which of the host's check sources a call may consult.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum Consult {
49    /// The rollup, and Actions when the credential is not allowed to read it.
50    Either,
51    /// The complete status-check rollup only.
52    StatusChecks,
53    /// Actions jobs together with the branch rules that say which ones block.
54    Actions,
55}
56
57/// What [`gh::CHECK_SOURCE_ENV`] narrows a call to, or [`Consult::Either`].
58///
59/// The knob exists because the fallback is not free: a credential that will never
60/// be allowed to read check runs is refused *every* time the rollup is asked,
61/// which for a publication polling a change request is one failed call every few
62/// seconds. An operator who knows their token is a fine-grained one says so once.
63/// The value is checked here rather than acted on loosely — a misspelling that
64/// silently meant "auto" would look like the fallback working.
65fn consult() -> Result<Consult> {
66    let Some(raw) = std::env::var_os(gh::CHECK_SOURCE_ENV) else {
67        return Ok(Consult::Either);
68    };
69    match raw.to_string_lossy().trim().to_ascii_lowercase().as_str() {
70        "" | "auto" => Ok(Consult::Either),
71        "status-checks" => Ok(Consult::StatusChecks),
72        "actions" => Ok(Consult::Actions),
73        other => Err(invalid(format!(
74            "{} names {other:?}, which is not a check source this build can read: it must be \
75             \"auto\", \"status-checks\", or \"actions\"",
76            gh::CHECK_SOURCE_ENV
77        ))),
78    }
79}
80
81/// How many entries an Actions listing is asked for at once, which is the most
82/// GitHub will return in one page.
83const PAGE: u32 = 100;
84
85/// How GitHub declines something the credential is not allowed to reach, in both
86/// the shapes it declines it in: a GraphQL error naming the node it would not
87/// produce, and a REST 403. The wording is GitHub's, and it is the whole of the
88/// test because it is the whole of what distinguishes the two cases below.
89const CHECK_RUN_REFUSAL: &str = "GraphQL: Resource not accessible by personal access token";
90
91/// Whether a refusal is one this credential will *always* get.
92///
93/// The one distinction the fallback turns on. A credential that may not read check
94/// runs will never read them, so asking a narrower source instead is the only way
95/// to answer at all — but a rollup that came back garbled, or a host that would not
96/// say which of its checks block, is a *complete* answer having gone wrong, and
97/// quietly answering from GitHub Actions alone would drop whatever a third-party
98/// integration posted. A required check nobody looked at is how a merge that was
99/// never gated ends up looking like one that was.
100fn check_rollup_refused_for_pat(error: &Error) -> bool {
101    let Error::Invalid { reason } = error else {
102        return false;
103    };
104    reason.contains(CHECK_RUN_REFUSAL) && reason.contains("statusCheckRollup")
105}
106
107/// Everything `onevcs` asks of a repository's remote host.
108pub trait RemoteHost {
109    /// Who the host believes is calling.
110    fn authenticated_user(&self) -> Result<String>;
111
112    /// Open a change request.
113    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest>;
114
115    /// Every open change request from `head` into `base`.
116    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>>;
117
118    /// The checks the host is reporting on a change request, and which of its
119    /// sources that answer was read from.
120    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks>;
121
122    /// Store one check's log as an artifact and return its id.
123    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId>;
124
125    /// Merge a change request under a policy.
126    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome>;
127}
128
129/// Where a [`RemoteHost`] for one repository comes from.
130///
131/// A host is addressed at a repository rather than at an installation — every
132/// call `gh` makes carries a slug — so the seam a caller supplies is the factory
133/// rather than one host object. This is what makes the interface reachable: a
134/// publication asks the factory it was handed for the repository it is publishing
135/// to, and never names an implementation.
136pub trait Hosting {
137    /// The host that answers for the repository named `owner/name`.
138    // llmlint: ignore[invalid_states_unrepresentable] a validated slug newtype would be a
139    // public item beyond the one this seam is specified as, and it would have exactly one
140    // constructor — the check below. That check is where the value is decided: `GitHub::new`
141    // refuses anything that does not name one repository as `owner/name`, before the slug is
142    // interpolated into a single `gh --repo`. A caller cannot reach a host any other way.
143    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>>;
144}
145
146/// The factory that produces [`GitHub`] hosts, which is what a real run uses.
147///
148/// Private: the way to hold one is [`crate::Providers::real`], because a caller
149/// mixing a real GitHub into a run whose repository side is not git is asking for
150/// a combination neither half was written for.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
152pub(crate) struct GitHubHosting;
153
154impl Hosting for GitHubHosting {
155    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
156        Ok(Box::new(GitHub::new(slug)?))
157    }
158}
159
160/// What to open a change request for.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ChangeSpec {
163    /// The branch carrying the change.
164    // llmlint: ignore[invalid_states_unrepresentable] a validated branch-ref newtype is
165    // exactly what the publication path carries internally, and it cannot surface here:
166    // the contract fixes the matching `ChangeRequest.base` as `String` and names no ref
167    // type, so spelling one on this side of the same trait would add a public item the
168    // contract does not name and disagree with the type it does. Both names are handed to
169    // git before they reach a host, which is where the parser that decides them lives.
170    pub head: String,
171    /// The branch it targets, which for a stacked change is the branch below it.
172    pub base: String,
173    /// The title, which under squash-merge becomes the commit subject.
174    pub title: String,
175    /// The body. Absent means the host's default from the repository template.
176    pub body: Option<String>,
177}
178
179/// An open change request on the host.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct ChangeRequest {
182    /// The host's identifier for it.
183    pub id: ChangeId,
184    /// Where a human reads it.
185    pub url: Url,
186    /// The commit its checks are reported against.
187    pub head_sha: Sha,
188    /// The branch it targets.
189    // llmlint: ignore[invalid_states_unrepresentable] the contract declares this field
190    // verbatim as `pub base: String` and names no ref type to narrow it to; changing it
191    // is changing the interface, which is reported rather than done. Every one this crate
192    // constructs is read out of a host response naming a branch the host already has.
193    pub base: String,
194}
195
196/// A host's identifier for a change request.
197// llmlint: ignore[invalid_states_unrepresentable] the contract declares this as
198// `id: ChangeId` and fixes nothing about its content — GitHub numbers its pull
199// requests, and another host may not. Every one this crate constructs is read out of
200// a host response that is required to carry it: `find_changes` rejects an entry with
201// no number, and `open_change` rejects output that printed no URL to take one from.
202#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
203#[serde(transparent)]
204pub struct ChangeId(pub String);
205
206/// A commit hash.
207// llmlint: ignore[invalid_states_unrepresentable] the contract declares this field as
208// `head_sha: Sha` and fixes nothing about its content, and a hash's shape is the host's
209// to state — a SHA-1 hex string today, something else on a repository that has moved.
210// Every one this crate constructs is validated where it enters: `head_sha` and
211// `merged_sha` below both reject a response that names no commit rather than
212// constructing an empty one, so no code path here can produce a blank `Sha`.
213#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
214#[serde(transparent)]
215pub struct Sha(pub String);
216
217/// One check the host reports on a change request.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Check {
220    /// The check's name, as branch protection lists it.
221    pub name: String,
222    /// Where the check is: the host's own status vocabulary, passed through.
223    // llmlint: ignore[invalid_states_unrepresentable] the contract fixes this field name
224    // and enumerates no value set for it, and the vocabulary differs per host — which is
225    // the thing this crate exists to abstract. Inventing an enum here would add a public
226    // item the contract does not name. The one word this build reads out of it —
227    // `completed` — is read in `settled` below and nowhere else, so a host that spells
228    // its states differently is understood by a second implementation of that predicate
229    // rather than by a new variant here. Recorded as open question 1 in
230    // docs/inferred-surface.md for the planner to settle across the three repositories.
231    pub status: String,
232    /// How it ended, once it has. Absent while it is still running.
233    // llmlint: ignore[invalid_states_unrepresentable] `conclusion` is the other half of the
234    // same open question as `status` above, for the same reason: the contract names the
235    // field and enumerates no conclusion vocabulary, and each host spells its own. The
236    // three this build treats as not blocking a merge are read in `green` below, which is
237    // the one place a second host's vocabulary would be taught.
238    pub conclusion: Option<String>,
239    /// Whether it blocks the merge.
240    pub required: bool,
241}
242
243impl Check {
244    /// Whether this check has reached a terminal state.
245    pub fn settled(&self) -> bool {
246        self.status.eq_ignore_ascii_case("completed")
247    }
248
249    /// Whether a settled check ended in a way that does not block a merge.
250    pub fn green(&self) -> bool {
251        self.settled()
252            && self.conclusion.as_deref().is_some_and(|value| {
253                matches!(
254                    value.to_ascii_lowercase().as_str(),
255                    "success" | "skipped" | "neutral"
256                )
257            })
258    }
259
260    /// Whether a settled check ended in a way that blocks a merge.
261    pub fn red(&self) -> bool {
262        self.settled() && !self.green()
263    }
264}
265
266/// One place a host's answer about a change request's checks was read from.
267///
268/// A credential decides which of these can be reached, so which were consulted is
269/// part of the answer rather than a property of the build: a fine-grained personal
270/// access token cannot read check runs *at all* — GitHub offers no `Checks`
271/// permission for one, and the permission it briefly had was withdrawn — so for
272/// such a token the rollup is unreadable and Actions is everything it can see.
273#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
274#[serde(rename_all = "kebab-case")]
275pub enum CheckSource {
276    /// The host's own rollup of every check on the change request: workflow jobs,
277    /// check runs a third-party integration posted, and commit statuses alike,
278    /// each carrying whether it blocks the merge. Complete, and the one source a
279    /// fine-grained token cannot read.
280    StatusChecks,
281    /// GitHub Actions' own view of the head commit: the jobs of the workflow runs
282    /// on it and nothing else. A check that ran no workflow — anything a
283    /// third-party integration posted as a check run or a commit status — is not
284    /// in it and cannot be seen from it.
285    Actions,
286    /// The repository's rulesets, for which checks block a merge into the base.
287    /// Narrower than the rollup's own answer: it reports rulesets and not classic
288    /// branch protection, so a repository protected the classic way reports
289    /// nothing blocking here — which the publication path fails closed on, waiting
290    /// for a required check that never arrives rather than merging without one.
291    BranchRules,
292}
293
294/// What a host reports about a change request's checks, and where it looked.
295///
296/// The sources travel with the checks because a caller deciding whether a change
297/// may merge has to be able to tell "these are all the checks" from "these are the
298/// workflow checks, and anything a third-party integration posted is invisible to
299/// this credential". Reporting the second as the first is how a merge gets waved
300/// through by a check nobody could see.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct ChangeChecks {
303    /// The checks themselves.
304    pub checks: Vec<Check>,
305    /// Every source consulted to produce them. Never empty: a host that could read
306    /// none of its sources is a refusal rather than an answer.
307    // llmlint: ignore[invalid_states_unrepresentable] the approved contract fixes
308    // this as a public BTreeSet; implementations refuse an empty answer.
309    pub sources: BTreeSet<CheckSource>,
310}
311
312impl ChangeChecks {
313    /// Whether this answer covers every check the host reports.
314    ///
315    /// False means the checks above are GitHub Actions' and only those.
316    pub fn complete(&self) -> bool {
317        self.sources.contains(&CheckSource::StatusChecks)
318    }
319}
320
321/// What merging a change request did.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "kebab-case")]
324pub enum MergeOutcome {
325    /// It merged, at this commit.
326    Merged(Sha),
327    /// The host queued it and will merge it once its checks pass.
328    Queued,
329    /// It was left open for review, which the policy asked for.
330    Open,
331}
332
333/// The GitHub implementation of [`RemoteHost`], driven through `gh`.
334///
335/// Every method is one `gh` invocation, and the program that answers as `gh` is the
336/// seam a journey substitutes: GitHub's decisioning is what a test replaces, never
337/// the git underneath it.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct GitHub {
340    /// The `owner/name` slug every call is addressed to. Private, so the only way
341    /// to hold one is to have had it accepted by [`GitHub::new`] — every `gh`
342    /// invocation below trusts it as the repository it addresses.
343    repo: String,
344}
345
346impl GitHub {
347    /// Address this host at one repository, named `owner/name`.
348    ///
349    /// Checked here rather than at each call: the slug is interpolated into every
350    /// `gh --repo` invocation, and a value that is not one repository is a value
351    /// that addresses something nobody asked for.
352    pub fn new(repo: impl Into<String>) -> Result<Self> {
353        let repo = repo.into();
354        let mut parts = repo.split('/');
355        let named = matches!(
356            (parts.next(), parts.next(), parts.next()),
357            (Some(owner), Some(name), None)
358                if !owner.is_empty()
359                    && !name.is_empty()
360                    && !repo.starts_with('-')
361                    && !repo.contains(char::is_whitespace)
362        );
363        if !named {
364            return Err(invalid(format!(
365                "{repo:?} does not name one repository as owner/name"
366            )));
367        }
368        Ok(Self { repo })
369    }
370
371    /// The names of the checks the host says block the merge.
372    ///
373    /// A second call, and it has to be one: `gh pr view`'s `statusCheckRollup`
374    /// carries a check's name, status, and conclusion and *nothing* about whether it
375    /// blocks anything. This build asked it for `isRequired` anyway and read the
376    /// field's absence as a host answering partially, so every `change_checks`
377    /// against real GitHub failed — a field `gh pr view` has never returned, checked
378    /// only ever against a stand-in that returned it. `gh pr checks --required` is
379    /// where `gh` reports it.
380    fn required_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
381        addressable(&cr.id.0, "change request id")?;
382        let answer = gh::attempt(&[
383            "pr",
384            "checks",
385            &cr.id.0,
386            "--repo",
387            &self.repo,
388            "--required",
389            "--json",
390            "name",
391        ])?;
392        if !usable(&answer) {
393            // A repository that declares no required check at all is answering, not
394            // failing: refusing here would make every unprotected repository
395            // unreadable, and "nothing blocks the merge" is what the publication
396            // path already knows how to act on.
397            if no_required_checks(&answer) {
398                return Ok(BTreeSet::new());
399            }
400            return Err(unsaid(&cr.url, &answer.detail()));
401        }
402        let value = gh::json(&answer.stdout)?;
403        let entries = value
404            .as_array()
405            .ok_or_else(|| unsaid(&cr.url, &value.to_string()))?;
406        let mut names = BTreeSet::new();
407        for entry in entries {
408            let name = entry
409                .get("name")
410                .and_then(|value| value.as_str())
411                .filter(|value| !value.is_empty())
412                .ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
413            names.insert(name.to_owned());
414        }
415        Ok(names)
416    }
417
418    /// The log of the job one check ran in.
419    ///
420    /// Two calls, because `gh` addresses a job's log by the job's *id* and a check's
421    /// name is not one. This build passed the name to `gh run view --job`, which has
422    /// never accepted anything but a number — so the artifact every settled check
423    /// carried held a refusal to produce a log rather than the log, and no offline
424    /// journey could tell, because the program standing in for `gh` answered to the
425    /// name. The id is the last segment of the details URL `gh pr checks` reports.
426    fn job_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
427        addressable(&cr.id.0, "change request id")?;
428        let answer = gh::attempt(&[
429            "pr",
430            "checks",
431            &cr.id.0,
432            "--repo",
433            &self.repo,
434            "--json",
435            "name,link",
436        ])?;
437        if !usable(&answer) {
438            return Err(invalid(format!(
439                "gh pr checks would not say where check {name:?} on {} ran: {}",
440                cr.url,
441                answer.detail()
442            )));
443        }
444        let value = gh::json(&answer.stdout)?;
445        // Rejected here rather than searched through: a host that answered with
446        // something other than a list of checks has not said this check has no job,
447        // and reporting it as though it had would put the wrong reason in the
448        // artifact an operator reads to find out why there is no log.
449        let entries = value.as_array().ok_or_else(|| {
450            invalid(format!(
451                "gh pr checks returned a non-list of checks on {}, so where check {name:?} ran \
452                 cannot be read from it: {value}",
453                cr.url
454            ))
455        })?;
456        let link = entries
457            .iter()
458            .find(|entry| entry.get("name").and_then(|value| value.as_str()) == Some(name))
459            .and_then(|entry| entry.get("link"))
460            .and_then(|value| value.as_str())
461            .ok_or_else(|| {
462                invalid(format!(
463                    "the host reports no job for check {name:?} on {}",
464                    cr.url
465                ))
466            })?;
467        let job = link
468            .rsplit_once("/job/")
469            .map(|(_, id)| id)
470            .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))
471            .ok_or_else(|| {
472                invalid(format!(
473                    "the host says check {name:?} ran at {link:?}, which names no job this build \
474                     can ask for a log"
475                ))
476            })?;
477        gh::invoke_content(&["run", "view", "--repo", &self.repo, "--log", "--job", job])
478    }
479
480    /// One check's log, from whichever source this credential can read.
481    ///
482    /// The same order [`RemoteHost::change_checks`] consults them in, for the same
483    /// reason: `gh pr checks` reports where *every* check ran, including one no
484    /// workflow produced, and the Actions API answers for the ones it owns when the
485    /// credential may not read the rollup at all.
486    fn log_of(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
487        match consult()? {
488            Consult::StatusChecks => self.job_log(cr, name),
489            Consult::Actions => self.actions_log(cr, name),
490            // As above, and for the same reason: where a check ran is a question
491            // `gh pr checks` can answer about every check, and only a credential
492            // that may not ask it at all has cause to ask a narrower source.
493            Consult::Either => self.job_log(cr, name).or_else(|reported| {
494                if !check_rollup_refused_for_pat(&reported) {
495                    return Err(reported);
496                }
497                self.actions_log(cr, name).map_err(|actions| {
498                    invalid(format!(
499                        "neither of GitHub's check sources would produce the log of check \
500                         {name:?} on {}: gh pr checks answered {reported}, and the Actions API \
501                         answered {actions}",
502                        cr.url
503                    ))
504                })
505            }),
506        }
507    }
508
509    /// Every check the host's own rollup reports on a change request.
510    ///
511    /// Complete — a workflow job, a check run some integration posted, and a commit
512    /// status all appear here — and readable only by a credential the repository
513    /// allows to read its check runs. A fine-grained personal access token is not
514    /// one and cannot become one, which is what [`GitHub::actions_checks`] answers
515    /// instead.
516    fn rollup_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
517        let value = self.view(&cr.id.0, "statusCheckRollup")?;
518        let reported = value.get("statusCheckRollup").ok_or_else(|| {
519            invalid(format!(
520                "gh pr view reported no checks at all on {}",
521                cr.url
522            ))
523        })?;
524        if reported.is_null() {
525            return Ok(Vec::new());
526        }
527        let rollup = reported
528            .as_array()
529            .ok_or_else(|| invalid(format!("gh pr view returned a non-list rollup: {reported}")))?;
530        if rollup.is_empty() {
531            return Ok(Vec::new());
532        }
533        let required = self.required_checks(cr)?;
534        rollup
535            .iter()
536            .map(|entry| check(entry, cr, &required))
537            .collect()
538    }
539
540    /// The checks GitHub Actions reports for the change request's head commit.
541    ///
542    /// One workflow job is one check, which is the same unit the rollup reports
543    /// them in — GitHub posts a check run per job — so what a caller waits for is
544    /// unchanged. What it cannot see is anything that ran no workflow, which is why
545    /// the answer says which sources it came from rather than passing itself off as
546    /// the whole picture.
547    fn actions_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
548        let jobs = self.actions_jobs(cr)?;
549        if jobs.is_empty() {
550            // Nothing has run yet, which is the first seconds of every change
551            // request. There is no check to ask the rulesets about, so they are not
552            // asked and the answer does not claim they were.
553            return Ok(ChangeChecks {
554                checks: Vec::new(),
555                sources: [CheckSource::Actions].into_iter().collect(),
556            });
557        }
558        let required = self.ruled_checks(cr)?;
559        Ok(ChangeChecks {
560            checks: jobs
561                .into_iter()
562                .map(|job| Check {
563                    required: required.contains(&job.name),
564                    name: job.name,
565                    status: job.status,
566                    conclusion: job.conclusion,
567                })
568                .collect(),
569            sources: [CheckSource::Actions, CheckSource::BranchRules]
570                .into_iter()
571                .collect(),
572        })
573    }
574
575    /// Every job of every workflow run on the change request's head commit.
576    ///
577    /// Two calls per run, because the Actions API reports a *run*'s status at the
578    /// run and a job's at the job, and a run that is still going says nothing about
579    /// which of its jobs have finished.
580    fn actions_jobs(&self, cr: &ChangeRequest) -> Result<Vec<Job>> {
581        let sha = commit(&cr.head_sha)?;
582        let runs = self.api(&format!(
583            "repos/{}/actions/runs?head_sha={sha}&per_page={PAGE}",
584            self.repo
585        ))?;
586        let mut jobs = Vec::new();
587        for run in listed(&runs, "workflow_runs", &format!("the commit {sha}"))? {
588            let id = run
589                .get("id")
590                .and_then(serde_json::Value::as_u64)
591                .ok_or_else(|| {
592                    invalid(format!(
593                        "the Actions API reported a workflow run on {} with no id: {run}",
594                        cr.url
595                    ))
596                })?;
597            let listing = self.api(&format!(
598                "repos/{}/actions/runs/{id}/jobs?per_page={PAGE}",
599                self.repo
600            ))?;
601            for entry in listed(&listing, "jobs", &format!("workflow run {id}"))? {
602                jobs.push(job(entry, cr)?);
603            }
604        }
605        Ok(jobs)
606    }
607
608    /// The names of the checks the repository's rulesets say block a merge into the
609    /// change request's base.
610    ///
611    /// The second question `gh pr checks --required` answers and a credential that
612    /// cannot read check runs cannot ask, because that command reads the same
613    /// rollup. This is what such a credential *can* read — and it is narrower, in a
614    /// direction that is safe rather than quiet: it reports rulesets and not classic
615    /// branch protection, so a repository protected the classic way answers "nothing
616    /// blocks" and the publication path then waits for a required check that never
617    /// arrives and stops. Under-reporting what blocks holds a merge; over-reporting
618    /// what has passed lets one through, and only one of those is recoverable.
619    fn ruled_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
620        addressable_branch(&cr.base, "the base branch")?;
621        let value = self.api(&format!(
622            "repos/{}/rules/branches/{}",
623            self.repo,
624            path_segment(&cr.base)
625        ))?;
626        let rules = value.as_array().ok_or_else(|| {
627            invalid(format!(
628                "the rules on {}'s base branch {:?} came back as something that is not a list of \
629                 them, so which of its checks block the merge cannot be read from it: {value}",
630                cr.url, cr.base
631            ))
632        })?;
633        let mut names = BTreeSet::new();
634        for rule in rules {
635            if rule.get("type").and_then(|value| value.as_str()) != Some("required_status_checks") {
636                continue;
637            }
638            // A rule that says it requires status checks and then will not say
639            // which is the one shape that must not be read as "none": that is the
640            // difference between a merge that was gated and one that only looked
641            // like it.
642            let required = rule
643                .get("parameters")
644                .and_then(|parameters| parameters.get("required_status_checks"))
645                .and_then(|value| value.as_array())
646                .ok_or_else(|| unsaid(&cr.url, &rule.to_string()))?;
647            for entry in required {
648                let name = entry
649                    .get("context")
650                    .and_then(|value| value.as_str())
651                    .filter(|value| !value.is_empty())
652                    .ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
653                names.insert(name.to_owned());
654            }
655        }
656        Ok(names)
657    }
658
659    /// The log of the Actions job one check ran in, addressed by the job's id.
660    ///
661    /// The id comes from the same listing the checks did, so the name matched here
662    /// is the name reported there — the two cannot drift apart the way a check's
663    /// name and a details URL parsed out of a second command can.
664    fn actions_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
665        let job = self
666            .actions_jobs(cr)?
667            .into_iter()
668            .find(|job| job.name == name)
669            .ok_or_else(|| {
670                invalid(format!(
671                    "GitHub Actions reports no job named {name:?} on the head commit of {}",
672                    cr.url
673                ))
674            })?;
675        gh::invoke_content(&[
676            "api",
677            &format!("repos/{}/actions/jobs/{}/logs", self.repo, job.id),
678        ])
679    }
680
681    fn api(&self, path: &str) -> Result<serde_json::Value> {
682        gh::json(&gh::invoke(&["api", path])?)
683    }
684
685    /// One change request as `gh` describes it, in the fields the caller reads and
686    /// no others.
687    ///
688    /// The field list is the caller's, not a constant, because `gh pr view` fails
689    /// *whole* when GitHub refuses one field of the several it was asked for — and
690    /// the fields differ in what a credential has to be allowed to see.
691    /// `statusCheckRollup` resolves the change request's check runs, which a
692    /// fine-grained token may not read without the repository's Checks permission,
693    /// while `state`, `headRefOid`, and `mergeCommit` need no more than the read
694    /// access that found the change request at all. This build asked for all of them
695    /// at every call, so a token that could open and merge a change request was
696    /// refused at both over a field neither reads — and only from the moment a check
697    /// first appeared on it, so the same credential merged a young change request and
698    /// failed on an older one. Ask for what you read.
699    fn view(&self, id: &str, fields: &str) -> Result<serde_json::Value> {
700        addressable(id, "change request id")?;
701        let raw = gh::invoke(&["pr", "view", id, "--repo", &self.repo, "--json", fields])?;
702        gh::json(&raw)
703    }
704}
705
706/// One value bound for `gh`'s argument vector, checked before it gets there.
707///
708/// Every string in these methods arrives from outside — off a caller embedding this
709/// crate, or out of the host's own answer — and each becomes a positional or an
710/// option's value. `gh` reads a leading `-` as an option of its own and an empty
711/// string as a present-but-blank value, so a name shaped like either is refused
712/// here rather than silently addressing something other than what it names.
713fn addressable(value: &str, what: &str) -> Result<()> {
714    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
715        return Err(invalid(format!(
716            "{what} {value:?} cannot address anything on the host: it must be non-empty, must \
717             not begin with '-', and must carry no whitespace"
718        )));
719    }
720    Ok(())
721}
722
723/// One value bound for a URL path segment, percent-encoded.
724///
725/// A branch name is the only one of these, and git allows characters in one that a
726/// path does not — a `/` above all, which every stacked branch carries and which
727/// would otherwise address a resource nobody asked for. Encoded rather than
728/// refused: `feature/thing` is a branch a repository really has.
729fn path_segment(value: &str) -> String {
730    let mut encoded = String::with_capacity(value.len());
731    for byte in value.bytes() {
732        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
733            encoded.push(char::from(byte));
734        } else {
735            encoded.push_str(&format!("%{byte:02X}"));
736        }
737    }
738    encoded
739}
740
741/// A commit hash bound for a query string, checked before it gets there.
742///
743/// It arrives off a host response and goes on to be one parameter of several, so a
744/// value carrying anything but hex digits could address a query nobody wrote.
745fn commit(sha: &Sha) -> Result<&str> {
746    if sha.0.is_empty() || !sha.0.chars().all(|c| c.is_ascii_hexdigit()) {
747        return Err(invalid(format!(
748            "{:?} is not a commit hash, so the checks reported against it cannot be asked for",
749            sha.0
750        )));
751    }
752    Ok(&sha.0)
753}
754
755/// One workflow job, as the Actions API reports it.
756struct Job {
757    /// What `gh api .../actions/jobs/<id>/logs` addresses its log by.
758    id: u64,
759    /// The job's name, which is the name GitHub posts its check run under.
760    name: String,
761    /// Where it is, in the host's own vocabulary.
762    status: String,
763    /// How it ended, once it has.
764    conclusion: Option<String>,
765}
766
767/// One job of an Actions listing, required to say what it is.
768///
769/// Defaulting a missing field is what would let a job that is still running be read
770/// as a green check, which is the shape that must never be inferred — the same rule
771/// [`check`] below is held to, for the same reason.
772fn job(entry: &serde_json::Value, cr: &ChangeRequest) -> Result<Job> {
773    let field = |name: &str| -> Result<&str> {
774        entry
775            .get(name)
776            .and_then(|value| value.as_str())
777            .filter(|value| !value.is_empty())
778            .ok_or_else(|| {
779                invalid(format!(
780                    "the Actions API returned a job on {} with no {name}: {entry}",
781                    cr.url
782                ))
783            })
784    };
785    Ok(Job {
786        id: entry
787            .get("id")
788            .and_then(serde_json::Value::as_u64)
789            .ok_or_else(|| {
790                invalid(format!(
791                    "the Actions API returned a job on {} with no id, so its log cannot be asked \
792                     for: {entry}",
793                    cr.url
794                ))
795            })?,
796        name: field("name")?.to_owned(),
797        status: field("status")?.to_ascii_lowercase(),
798        // Genuinely absent while a job is still running.
799        conclusion: entry
800            .get("conclusion")
801            .and_then(|value| value.as_str())
802            .filter(|value| !value.is_empty())
803            .map(str::to_ascii_lowercase),
804    })
805}
806
807/// The entries of one Actions listing, refused unless the page carried them all.
808///
809/// These endpoints page, and a page that held entries back is a partial answer
810/// about what ran — read as the whole of it, a check nobody saw is a check nobody
811/// waited for. `total_count` is how the answer says how many there are, so the two
812/// are compared rather than the list being trusted to be complete.
813fn listed<'a>(
814    value: &'a serde_json::Value,
815    field: &str,
816    what: &str,
817) -> Result<&'a Vec<serde_json::Value>> {
818    let entries = value
819        .get(field)
820        .and_then(|value| value.as_array())
821        .ok_or_else(|| {
822            invalid(format!(
823                "the Actions API answered about {what} with something that lists no {field}: \
824                 {value}"
825            ))
826        })?;
827    let total = value
828        .get("total_count")
829        .and_then(serde_json::Value::as_u64)
830        .ok_or_else(|| {
831            invalid(format!(
832                "the Actions API did not say how many {field} there are on {what}: {value}"
833            ))
834        })?;
835    if total > entries.len() as u64 {
836        return Err(invalid(format!(
837            "the Actions API reports {total} {field} on {what} and returned {}, so this build has \
838             not been shown all of them",
839            entries.len()
840        )));
841    }
842    Ok(entries)
843}
844
845/// The refusal for a credential that can read none of the host's check sources.
846///
847/// Both refusals, verbatim, and the permission that answers them — because the
848/// thing an operator cannot tell from GitHub's own words is that one of these is
849/// not a scope to widen: a fine-grained personal access token has no `Checks`
850/// permission to grant, so the rollup is out of reach for that credential class
851/// however it is scoped, and `Actions: Read` is what makes the other source work.
852fn unreadable(repo: &str, cr: &ChangeRequest, rollup: &Error, actions: &Error) -> Error {
853    invalid(format!(
854        "neither of GitHub's check sources could be read for {}, so what its checks say is \
855         unknown rather than empty. Its check rollup answered: {rollup}. Its Actions API \
856         answered: {actions}. Grant this credential `Actions: Read` on {repo} to read the \
857         repository's workflow checks — a fine-grained personal access token cannot read the \
858         rollup at all, whatever it is scoped to, because GitHub offers no Checks permission for \
859         one.",
860        cr.url
861    ))
862}
863
864/// A branch name a caller supplied, checked by the parser that decides branch names.
865fn addressable_branch(value: &str, what: &str) -> Result<()> {
866    if !git::is_valid_branch_name(value) {
867        return Err(invalid(format!(
868            "{what} {value:?} is a name git would not accept"
869        )));
870    }
871    Ok(())
872}
873
874impl RemoteHost for GitHub {
875    fn authenticated_user(&self) -> Result<String> {
876        let login = gh::invoke(&["api", "user", "--jq", ".login"])?
877            .trim()
878            .to_owned();
879        if login.is_empty() {
880            return Err(Error::Invalid {
881                reason: "gh reported no authenticated user".to_owned(),
882            });
883        }
884        Ok(login)
885    }
886
887    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
888        addressable_branch(&req.head, "the head branch")?;
889        addressable_branch(&req.base, "the base branch")?;
890        let body = req.body.unwrap_or_default();
891        let raw = gh::invoke(&[
892            "pr", "create", "--repo", &self.repo, "--head", &req.head, "--base", &req.base,
893            "--title", &req.title, "--body", &body,
894        ])?;
895        let url = raw
896            .lines()
897            .map(str::trim)
898            .rfind(|line| line.starts_with("http"))
899            .ok_or_else(|| invalid(format!("gh pr create printed no URL: {raw:?}")))?;
900        let parsed = Url::parse(url)
901            .map_err(|e| invalid(format!("gh pr create printed {url:?}, not a URL: {e}")))?;
902        // The host numbers its change requests, and the number is the last segment
903        // of the URL it printed. Anything else in that position is `gh` having
904        // printed something other than a change request's URL, which is not an
905        // identifier to go on addressing it by.
906        let id = parsed
907            .path_segments()
908            .and_then(|mut segments| segments.next_back())
909            .filter(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
910            .ok_or_else(|| {
911                invalid(format!(
912                    "gh pr create printed {url:?}, which names no change"
913                ))
914            })?
915            .to_owned();
916        Ok(ChangeRequest {
917            // The commit the new change request's checks will be reported against,
918            // which `gh pr create` does not print.
919            head_sha: head_sha(&self.view(&id, "headRefOid")?)?,
920            id: ChangeId(id),
921            url: parsed,
922            base: req.base,
923        })
924    }
925
926    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
927        addressable_branch(head, "the head branch")?;
928        addressable_branch(base, "the base branch")?;
929        let raw = gh::invoke(&[
930            "pr",
931            "list",
932            "--repo",
933            &self.repo,
934            "--head",
935            head,
936            "--base",
937            base,
938            "--state",
939            "open",
940            "--json",
941            "number,url,state,headRefOid",
942        ])?;
943        let value = gh::json(&raw)?;
944        let items = value
945            .as_array()
946            .ok_or_else(|| invalid(format!("gh pr list returned {raw:?}, not a list")))?;
947        let mut changes = Vec::new();
948        for item in items {
949            let url = item.get("url").and_then(|v| v.as_str()).unwrap_or_default();
950            let parsed = Url::parse(url)
951                .map_err(|e| invalid(format!("gh pr list returned {url:?}, not a URL: {e}")))?;
952            let number = item
953                .get("number")
954                .and_then(|value| value.as_u64())
955                .ok_or_else(|| invalid(format!("gh pr list returned no number: {raw:?}")))?
956                .to_string();
957            changes.push(ChangeRequest {
958                id: ChangeId(number),
959                url: parsed,
960                head_sha: head_sha(item)?,
961                base: base.to_owned(),
962            });
963        }
964        Ok(changes)
965    }
966
967    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
968        let rollup = |checks| ChangeChecks {
969            checks,
970            sources: [CheckSource::StatusChecks].into_iter().collect(),
971        };
972        match consult()? {
973            Consult::StatusChecks => self.rollup_checks(cr).map(rollup),
974            Consult::Actions => self.actions_checks(cr),
975            // The rollup first, because it is the complete answer and this build
976            // cannot know what the credential is until it is refused. A refusal is
977            // never read as "no checks": the fallback either produces an answer of
978            // its own or both refusals are reported together.
979            Consult::Either => match self.rollup_checks(cr) {
980                Ok(checks) => Ok(rollup(checks)),
981                Err(refused) if check_rollup_refused_for_pat(&refused) => self
982                    .actions_checks(cr)
983                    .map_err(|actions| unreadable(&self.repo, cr, &refused, &actions)),
984                Err(refused) => Err(refused),
985            },
986        }
987    }
988
989    /// Store one check's log as an artifact, or refuse.
990    ///
991    /// A fetch that did not produce the log is an error and no artifact: an
992    /// artifact reads as what the check printed, so one holding the reason there is
993    /// none is a lie in the only record a consumer has. A caller that must not fail
994    /// over a missing log declines to fail over this error, as [`crate::publish`]
995    /// does.
996    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
997        // A name that cannot address a job and a host that would not produce the log
998        // are the same event to a caller — there is no log — and read the same.
999        let log = addressable(&check.name, "check name")
1000            .and_then(|()| self.log_of(cr, &check.name))
1001            .map_err(|error| {
1002                invalid(format!(
1003                    "the host could not produce a log for check {:?} on {}: {error}",
1004                    check.name, cr.url
1005                ))
1006            })?;
1007        Ok(stream::store_artifact("log", &log)?.id)
1008    }
1009
1010    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
1011        match policy {
1012            MergePolicy::LocalDirect | MergePolicy::ChangeOpen => Ok(MergeOutcome::Open),
1013            MergePolicy::ChangeAuto => {
1014                addressable(&cr.id.0, "change request id")?;
1015                gh::invoke(&[
1016                    "pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash", "--auto",
1017                ])?;
1018                let view = self.view(&cr.id.0, MERGE_FIELDS)?;
1019                Ok(match merged_sha(&view, cr)? {
1020                    Some(sha) => MergeOutcome::Merged(sha),
1021                    None => MergeOutcome::Queued,
1022                })
1023            }
1024            MergePolicy::ChangeDirect => {
1025                addressable(&cr.id.0, "change request id")?;
1026                gh::invoke(&["pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash"])?;
1027                let view = self.view(&cr.id.0, MERGE_FIELDS)?;
1028                match merged_sha(&view, cr)? {
1029                    Some(sha) => Ok(MergeOutcome::Merged(sha)),
1030                    None => Err(Error::GateFailed {
1031                        reason: format!(
1032                            "the host accepted the merge of {} but reports it unmerged",
1033                            cr.url
1034                        ),
1035                    }),
1036                }
1037            }
1038        }
1039    }
1040}
1041
1042/// The refusal for a host that will not say which of its checks block the merge.
1043///
1044/// Inferring it is the one shape that must never be guessed: it is the difference
1045/// between a merge that was gated and one that only looked like it. What changed
1046/// with the real backend is *where* the question is asked, not that it may be
1047/// skipped — a host that answers `gh pr checks --required` with something other
1048/// than the checks it requires is refused here rather than read as requiring none.
1049fn unsaid(url: &Url, detail: &str) -> Error {
1050    invalid(format!(
1051        "gh pr checks --required answered about {url} with {detail}, so a check it reported does \
1052         not say whether it blocks the merge"
1053    ))
1054}
1055
1056/// One entry of the host's check rollup, required to say what it is.
1057///
1058/// Defaulting a missing field here is what would let a host that answered
1059/// partially be read as a green check — the one shape that must never be inferred.
1060/// Whether the check *blocks* is not in this entry at all and never was: it comes
1061/// from [`GitHub::required_checks`], which refuses a host that will not say.
1062fn check(
1063    entry: &serde_json::Value,
1064    cr: &ChangeRequest,
1065    required: &BTreeSet<String>,
1066) -> Result<Check> {
1067    let field = |name: &str| -> Result<&str> {
1068        entry
1069            .get(name)
1070            .and_then(|value| value.as_str())
1071            .filter(|value| !value.is_empty())
1072            .ok_or_else(|| {
1073                invalid(format!(
1074                    "gh pr view returned a check on {} with no {name}: {entry}",
1075                    cr.url
1076                ))
1077            })
1078    };
1079    let name = field("name").or_else(|_| field("context"))?.to_owned();
1080    let blocks = required.contains(&name);
1081    Ok(Check {
1082        name,
1083        status: field("status")?.to_ascii_lowercase(),
1084        // Genuinely absent while a check is still running, which is the one thing
1085        // the host cannot yet know.
1086        conclusion: entry
1087            .get("conclusion")
1088            .and_then(|value| value.as_str())
1089            .filter(|value| !value.is_empty())
1090            .map(str::to_ascii_lowercase),
1091        required: blocks,
1092    })
1093}
1094
1095/// What [`merged_sha`] reads, which is what a merge asks `gh pr view` for.
1096const MERGE_FIELDS: &str = "state,mergeCommit";
1097
1098/// The commit a merged change request landed as, or `None` while it is still open.
1099///
1100/// A host reporting `MERGED` without naming the commit is answering wrongly rather
1101/// than answering "not yet": the commit is the whole evidence that the change
1102/// reached its base, and reporting a merge with no SHA is the one thing that must
1103/// not be passed through.
1104fn merged_sha(view: &serde_json::Value, cr: &ChangeRequest) -> Result<Option<Sha>> {
1105    let state = view
1106        .get("state")
1107        .and_then(|value| value.as_str())
1108        .ok_or_else(|| invalid(format!("gh pr view returned no state for {}", cr.url)))?;
1109    if !state.eq_ignore_ascii_case("merged") {
1110        return Ok(None);
1111    }
1112    view.get("mergeCommit")
1113        .and_then(|commit| commit.get("oid"))
1114        .and_then(|value| value.as_str())
1115        .filter(|value| !value.is_empty())
1116        .map(|sha| Some(Sha(sha.to_owned())))
1117        .ok_or_else(|| {
1118            invalid(format!(
1119                "gh pr view reports {} merged without naming the commit it merged as",
1120                cr.url
1121            ))
1122        })
1123}
1124
1125/// The commit a change request's checks are reported against.
1126fn head_sha(view: &serde_json::Value) -> Result<Sha> {
1127    view.get("headRefOid")
1128        .and_then(|value| value.as_str())
1129        .filter(|value| !value.is_empty())
1130        .map(|sha| Sha(sha.to_owned()))
1131        .ok_or_else(|| invalid(format!("gh returned a change request with no head: {view}")))
1132}