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