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}