Skip to main content

onetaskgraph_github_projects/
accounting.rs

1//! What a session of requests to GitHub cost, counted rather than argued about.
2//!
3//! Nothing here decides anything: it records what left this crate and adds it up. It
4//! exists because a query strategy cannot be chosen between without measuring what each
5//! one costs.
6//!
7//! # What one record carries
8//!
9//! One [`Request`] per outgoing HTTP request. A GraphQL request is named by the document
10//! it sent, read out of [`graphql::DOCUMENTS`] rather than from
11//! a second list of names, and carries that document's worst-case node count under the
12//! bindings that request actually sent — [`node_count`], the same
13//! offline calculation `tests/node_count.rs` holds every document to, never a second
14//! arithmetic. A REST request sends no document and has no node count, so it names the
15//! endpoint it addressed instead. Both record whether they read or wrote, how they ended,
16//! and the rate-limit facts that response's own headers carried.
17//!
18//! **Two quantities of GitHub's, kept apart by name.** `nodeCount` is the most nodes *one
19//! query may return*, checked per query; it is what [`Call::Document`] carries.
20//! `cost` is rate-limit points, metered per hour across everything one credential does; it
21//! is what [`Spend`] is in. A document well under the node limit says nothing about the
22//! second.
23//!
24//! # How a session's spend is arrived at, and what it is not
25//!
26//! Per budget, accumulated per call, from whatever that call itself makes attributable:
27//!
28//! - **[`Budget::Rest`] is metered in requests,** so a call is its own measure and is
29//!   attributed one request ([`Basis::Counted`]).
30//! - **[`Budget::Graphql`] is metered in points.** Where the request was shaped so GitHub
31//!   reports its own `cost` — a document selecting `rateLimit { cost }` — that is what is
32//!   attributed ([`Basis::Reported`]). Otherwise this repository's stated cost model
33//!   applies: **GitHub charges at least one point for any call, so one point is
34//!   attributed** ([`Basis::Modelled`]), and the report says how much of the total came
35//!   that way. That is a **lower bound** and the accounting says so rather than implying a
36//!   measurement: a call over a large connection really costs more, and no document this
37//!   source sends today asks GitHub what.
38//! - **A rate-limited refusal is attributed nothing** ([`Basis::NotRun`]), because a
39//!   request GitHub refused for a rate limit did not run — the same reading of a refusal
40//!   that makes retrying one safe in [`GitHubProjectsSource::graphql`](crate::GitHubProjectsSource).
41//!
42//! **What a session spent is never inferred by differencing a shared counter.** This
43//! account is shared and rate-limited, and other work draws on the same budgets in the same
44//! window, so an allowance that fell by sixty while this session made ten calls measures
45//! the account rather than the session. The report gives that movement anyway — it is worth
46//! seeing — and says on its face that it is the account's and not this session's.
47//!
48//! # Where a reader finds the report
49//!
50//! [`Session::report`] renders one from a snapshot, and the credentialed lane in
51//! `tests/live.rs` prints it at the end of every run, passed or failed — from a `Drop`, so
52//! that the run whose cost is most worth reading, the one that broke, is not the run that
53//! skips it. It carries no credential, no token, no issue body and no board content: a call
54//! is named by a document description this crate wrote or by an [`Endpoint`], which is a
55//! path template rather than the URL a run built, and everything else in it is a number.
56
57use std::collections::BTreeMap;
58use std::fmt::Write as _;
59use std::sync::Mutex;
60
61use onetaskgraph_plugin_api::{Metered, Metering};
62use serde_json::Value;
63
64pub use reqwest::StatusCode;
65
66use crate::{Limiter, Variables, graphql, largest_page_sizes, node_count};
67
68/// Which of GitHub's two separately metered budgets a request drew on.
69///
70/// They are two because GitHub meters them separately, and a single total would hide which
71/// one is close to exhaustion.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub enum Budget {
74    /// The GraphQL API, metered in points.
75    Graphql,
76    /// The REST API, metered in requests.
77    Rest,
78}
79
80impl Budget {
81    /// This budget's name, as GitHub's own `x-ratelimit-resource` header spells it.
82    #[must_use]
83    pub const fn name(self) -> &'static str {
84        match self {
85            Self::Graphql => "graphql",
86            Self::Rest => "rest",
87        }
88    }
89    /// What GitHub meters this budget in.
90    #[must_use]
91    pub const fn unit(self) -> &'static str {
92        match self {
93            Self::Graphql => "points",
94            Self::Rest => "requests",
95        }
96    }
97}
98
99/// Whether a request read or wrote.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
101pub enum Mode {
102    /// It asked for something.
103    Read,
104    /// It changed something.
105    Write,
106}
107
108impl Mode {
109    /// What a GraphQL document does.
110    ///
111    /// Every mutation this source sends creates content and no query does, so the keyword
112    /// is the whole of the question — the same test [`crate::GitHubProjectsSource`] paces
113    /// its own writes by.
114    #[must_use]
115    pub fn of_document(document: &str) -> Self {
116        if crate::is_mutation(document) {
117            Self::Write
118        } else {
119            Self::Read
120        }
121    }
122    /// This mode's name in a report.
123    #[must_use]
124    pub const fn name(self) -> &'static str {
125        match self {
126            Self::Read => "read",
127            Self::Write => "write",
128        }
129    }
130}
131
132/// The HTTP methods a REST call to GitHub is made with.
133///
134/// A closed set rather than a string: a record cannot then carry a method that is not one,
135/// and a misspelling is refused by [`Method::parse`] where it happens rather than quietly
136/// counted as a write. It is this crate's own enum rather than an HTTP client's, so which
137/// client a caller sends its own requests with stays the caller's business.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
139pub enum Method {
140    /// `GET`.
141    Get,
142    /// `HEAD`.
143    Head,
144    /// `POST`.
145    Post,
146    /// `PUT`.
147    Put,
148    /// `PATCH`.
149    Patch,
150    /// `DELETE`.
151    Delete,
152}
153
154impl Method {
155    /// The method `name` spells, whatever its case, or `None` when it spells none of them.
156    #[must_use]
157    pub fn parse(name: &str) -> Option<Self> {
158        Some(match name.trim().to_ascii_uppercase().as_str() {
159            "GET" => Self::Get,
160            "HEAD" => Self::Head,
161            "POST" => Self::Post,
162            "PUT" => Self::Put,
163            "PATCH" => Self::Patch,
164            "DELETE" => Self::Delete,
165            _ => return None,
166        })
167    }
168    /// Its name, as HTTP spells it.
169    #[must_use]
170    pub const fn name(self) -> &'static str {
171        match self {
172            Self::Get => "GET",
173            Self::Head => "HEAD",
174            Self::Post => "POST",
175            Self::Put => "PUT",
176            Self::Patch => "PATCH",
177            Self::Delete => "DELETE",
178        }
179    }
180    /// Whether it reads or writes.
181    #[must_use]
182    pub const fn mode(self) -> Mode {
183        match self {
184            Self::Get | Self::Head => Mode::Read,
185            Self::Post | Self::Put | Self::Patch | Self::Delete => Mode::Write,
186        }
187    }
188}
189
190/// How one request ended.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
192pub enum Outcome {
193    /// GitHub answered with what was asked for.
194    Answered,
195    /// GitHub did not answer it, for a reason that is not a rate limit — including a
196    /// request that never reached GitHub at all, which carries no headers to read.
197    Refused,
198    /// A rate limiter refused it, so it never ran.
199    RateLimited,
200}
201
202impl Outcome {
203    /// How a response ended, read the way this source's own limiter reads one.
204    ///
205    /// `budget_exhausted` is whether `x-ratelimit-remaining` was exactly `0`, which
206    /// *explains* a failing response rather than making a successful one fail. A GraphQL
207    /// success carrying `errors` is a refusal only its caller can rule on, so this answers
208    /// [`Outcome::Answered`] for one and the caller narrows it.
209    ///
210    /// The status is a [`StatusCode`] rather than a number, so a status HTTP has no room
211    /// for cannot be asked about at all — and an HTTP client has already parsed one for
212    /// every response it hands back, so nothing is asked of a caller that it did not have.
213    #[must_use]
214    // llmlint: ignore[invalid_states_unrepresentable] `budget_exhausted` is one header read as the yes-or-no it is — whether `x-ratelimit-remaining` was exactly `0` — so both of its values are meaningful and there is no third state a type could forbid. It is also the argument [`Limiter::classify`] already takes, on the line below, so giving this one wrapper its own spelling would mean two vocabularies for one header rather than one.
215    pub fn of_response(status: StatusCode, budget_exhausted: bool, body: &str) -> Self {
216        if Limiter::classify(status, budget_exhausted, body).is_some() {
217            return Self::RateLimited;
218        }
219        if status.is_success() {
220            Self::Answered
221        } else {
222            Self::Refused
223        }
224    }
225    /// This outcome's name in a report.
226    #[must_use]
227    pub const fn name(self) -> &'static str {
228        match self {
229            Self::Answered => "answered",
230            Self::Refused => "refused",
231            Self::RateLimited => "rate-limited",
232        }
233    }
234}
235
236/// The rate-limit facts one response's own headers carried.
237///
238/// Every figure is optional because every one of them is absent from some real response: a
239/// request that never reached GitHub has no headers at all, and a refusal from an
240/// intermediary carries whichever of them that intermediary felt like carrying. An absent
241/// figure is reported as unknown rather than guessed at.
242///
243/// **These are observations of one response, so the only ways to have one are to observe a
244/// response ([`RateLimit::read`]) or to have observed nothing ([`RateLimit::default`]).**
245/// The fields are read through the accessors below rather than assembled: a hand-built set
246/// could say the account had more remaining than its whole allowance, or name a resource
247/// GitHub would never name, and a report built on it would be a measurement of nothing.
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub struct RateLimit {
250    /// `x-ratelimit-limit`: the whole allowance for this budget's window.
251    limit: Option<u64>,
252    /// `x-ratelimit-remaining`: what was left of it when GitHub answered.
253    remaining: Option<u64>,
254    /// `x-ratelimit-used`: what the *account* had spent, which is not what this session
255    /// spent — other work shares the budget.
256    used: Option<u64>,
257    /// `x-ratelimit-reset`: the Unix second the allowance comes back.
258    reset: Option<u64>,
259    /// `x-ratelimit-resource`: which budget GitHub says these figures are about.
260    resource: Option<String>,
261}
262
263impl RateLimit {
264    /// Read the five headers GitHub carries a budget's state in.
265    ///
266    /// `header` is given a lower-case header name and answers that response's value for
267    /// it. Taking a lookup rather than a header map is what keeps the HTTP client this
268    /// crate happens to use out of its public interface, so a caller sending its own
269    /// requests with its own client records into the same accounting.
270    #[must_use]
271    pub fn read(header: impl Fn(&str) -> Option<String>) -> Self {
272        let number = |name: &str| {
273            header(name)
274                .as_deref()
275                .map(str::trim)
276                .and_then(|value| value.parse::<u64>().ok())
277        };
278        // The three allowance figures are one fact and are read as one. A response saying
279        // more was left of a budget than the whole of it, or more of it used than it holds,
280        // cannot be true of any account — and a report built on it would be a measurement of
281        // nothing, which is what the type documentation above refuses. So a set that cannot
282        // all be true is a budget state this response did not carry: dropped **together**,
283        // because which of the three is the wrong one is not knowable from here, and
284        // reported as unknown rather than repaired into a figure nothing observed. `reset`
285        // and `resource` are independent of them and survive. `Allowance::read` in
286        // `onetaskgraph-live` refuses the same impossibility where the gate reads it.
287        let (limit, remaining, used) = (
288            number("x-ratelimit-limit"),
289            number("x-ratelimit-remaining"),
290            number("x-ratelimit-used"),
291        );
292        let over_the_whole = |figure: Option<u64>| match (figure, limit) {
293            (Some(figure), Some(limit)) => figure > limit,
294            _ => false,
295        };
296        // And the same impossibility one step on, which neither figure shows on its own:
297        // `used` and `remaining` are two views of one allowance — what the window has spent
298        // and what is left of it — so between them they cannot exceed the whole. A response
299        // saying 4,000 used and 4,000 left of 5,000 has each figure inside the limit and
300        // still cannot be true of any account.
301        //
302        // **Only that direction is refused, and a sum falling short of the whole is kept
303        // deliberately.** It accounts for less of the budget than exists rather than for
304        // more of it than could, which no arithmetic forbids and which a response really
305        // carries: the loopback board this crate is tested against answers `remaining: 0`
306        // beside a `used` naming what that session itself spent, wherever it stands in for a
307        // budget somebody else exhausted. Dropping those three would report the budget as
308        // unknown, which is a worse answer than a short one — and it could not reach what
309        // the report says this SESSION spent in any case, because that figure is attributed
310        // per call and is never differenced out of these.
311        let more_than_the_whole_between_them = match (used, remaining, limit) {
312            (Some(used), Some(remaining), Some(limit)) => used.saturating_add(remaining) > limit,
313            _ => false,
314        };
315        let readable = !over_the_whole(remaining)
316            && !over_the_whole(used)
317            && !more_than_the_whole_between_them;
318        Self {
319            limit: limit.filter(|_| readable),
320            remaining: remaining.filter(|_| readable),
321            used: used.filter(|_| readable),
322            reset: number("x-ratelimit-reset"),
323            // The one field here that is not a number, so the one that could carry a third
324            // party's arbitrary bytes into a value this crate hands back. GitHub names these
325            // `core`, `graphql`, `search`, `integration_manifest`; anything that is not
326            // spelled like one is dropped rather than stored.
327            resource: header("x-ratelimit-resource")
328                .map(|value| value.trim().to_owned())
329                .filter(|value| is_resource_name(value)),
330        }
331    }
332    /// The whole allowance for this budget's window, as this response reported it.
333    #[must_use]
334    pub const fn limit(&self) -> Option<u64> {
335        self.limit
336    }
337    /// What was left of it when GitHub answered.
338    #[must_use]
339    pub const fn remaining(&self) -> Option<u64> {
340        self.remaining
341    }
342    /// What the **account** had spent — not what this session spent, because other work
343    /// draws on the same budget in the same window.
344    #[must_use]
345    pub const fn used_by_the_account(&self) -> Option<u64> {
346        self.used
347    }
348    /// The Unix second the allowance comes back.
349    #[must_use]
350    pub const fn reset(&self) -> Option<u64> {
351        self.reset
352    }
353    /// Which budget GitHub said these figures were about, when it named one it spells.
354    #[must_use]
355    pub fn resource(&self) -> Option<&str> {
356        self.resource.as_deref()
357    }
358    /// Whether these headers say the budget is exactly spent, which is what
359    /// [`Outcome::of_response`] reads.
360    #[must_use]
361    pub fn exhausted(&self) -> bool {
362        self.remaining == Some(0)
363    }
364}
365
366/// Whether a value is spelled like one of GitHub's rate-limit resource names.
367///
368/// ASCII letters, digits, underscores and hyphens, and short. It is a shape rather than a
369/// list because GitHub adds resources — `code_search` arrived after `search` — and a name
370/// this does not recognise should be reported as GitHub sent it rather than dropped for
371/// being new.
372fn is_resource_name(value: &str) -> bool {
373    !value.is_empty()
374        && value.len() <= 64
375        && value
376            .bytes()
377            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
378}
379
380/// Where one call's attributed spend came from.
381///
382/// It is on the record rather than folded into the number so a report can say how much of
383/// a session's total is GitHub's own figure and how much is this repository's model.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
385pub enum Basis {
386    /// The budget is metered in requests, so the call is its own measure.
387    Counted,
388    /// GitHub reported this call's own cost, because the request asked it to.
389    Reported,
390    /// This repository's stated cost model: one point, GitHub's documented minimum for any
391    /// call, which is a lower bound rather than a measurement.
392    Modelled,
393    /// A rate limiter refused the request, so it never ran and is attributed nothing.
394    NotRun,
395}
396
397impl Basis {
398    /// This basis in a report, as a phrase that says what the figure is worth.
399    #[must_use]
400    pub const fn name(self) -> &'static str {
401        match self {
402            Self::Counted => "counted",
403            Self::Reported => "reported by GitHub",
404            Self::Modelled => "this repository's one-point-per-call lower bound",
405            Self::NotRun => "not run",
406        }
407    }
408}
409
410/// What one call is attributed against its budget, and where that figure came from.
411///
412/// **The amount and the basis are one fact, so they are settled together and read apart.**
413/// Three of the four bases fix the amount outright — a call against a budget metered in
414/// requests is one request, the model's lower bound is one point, and a request a rate
415/// limiter refused never ran and is nothing — and only [`Basis::Reported`] carries a figure
416/// of its own, GitHub's. Constructing the pair field by field would let a report say a call
417/// GitHub never ran spent forty points, which is a measurement of nothing; the four
418/// constructors below are the only ways to have one, and each is the invariant for its own
419/// basis.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub struct Spend {
422    amount: u64,
423    basis: Basis,
424}
425
426impl Spend {
427    /// One request against a budget metered in requests.
428    const fn counted() -> Self {
429        Self {
430            amount: 1,
431            basis: Basis::Counted,
432        }
433    }
434    /// What GitHub itself reported this call cost.
435    const fn reported(cost: u64) -> Self {
436        Self {
437            amount: cost,
438            basis: Basis::Reported,
439        }
440    }
441    /// This repository's lower bound: one point, GitHub's documented minimum for any call.
442    const fn modelled() -> Self {
443        Self {
444            amount: 1,
445            basis: Basis::Modelled,
446        }
447    }
448    /// Nothing, because a rate limiter refused the request and it never ran.
449    const fn not_run() -> Self {
450        Self {
451            amount: 0,
452            basis: Basis::NotRun,
453        }
454    }
455    /// The amount, in that budget's own unit.
456    #[must_use]
457    pub const fn amount(self) -> u64 {
458        self.amount
459    }
460    /// What makes it that amount.
461    #[must_use]
462    pub const fn basis(self) -> Basis {
463        self.basis
464    }
465}
466
467/// A REST endpoint, spelled the way GitHub's own documentation spells one.
468///
469/// `GET /repos/{owner}/{repo}/labels`: a method and a path template whose segments are
470/// literals or `{placeholder}`s, never the URL a run built from it. That is what makes two
471/// runs' reports compare line for line, and it is why this is a type with one constructor
472/// rather than a string a caller fills in — a record holding
473/// `https://api.github.com/repos/octo-org/board/labels?per_page=100` would put a board's
474/// name, and whatever else a query string carried, into a report that promises to carry
475/// neither.
476///
477/// **What it rules on, and what it cannot.** [`Endpoint::parse`] refuses a host, a query
478/// string, a fragment, whitespace, a byte outside the small set a path template is spelled
479/// from, and anything long — every way an addressed URL differs in *shape* from a template.
480/// It cannot tell a template's literal segment from a filled-in one, because
481/// `/repos/octo-org/board/labels` is spelled exactly like a template of literals; keeping
482/// the placeholders unfilled is the caller's, and the reason `Request::rest`'s own
483/// documentation says to pass the template.
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct Endpoint {
486    method: Method,
487    path: String,
488    name: String,
489}
490
491impl Endpoint {
492    /// The endpoint `path` spells under `method`, or `None` when `path` is not spelled like
493    /// a path template at all.
494    ///
495    /// Refusing it here is refusing it where it is written, which is the same answer
496    /// [`Method::parse`] gives a method HTTP has no verb for: a caller learns at the call
497    /// site rather than finding a URL in a report that was supposed to hold none.
498    #[must_use]
499    pub fn parse(method: Method, path: &str) -> Option<Self> {
500        if !is_path_template(path) {
501            return None;
502        }
503        Some(Self {
504            method,
505            path: path.to_owned(),
506            name: format!("{} {path}", method.name()),
507        })
508    }
509    /// The method it is addressed with.
510    #[must_use]
511    pub const fn method(&self) -> Method {
512        self.method
513    }
514    /// The path template, without the method.
515    #[must_use]
516    pub fn path(&self) -> &str {
517        &self.path
518    }
519    /// What it is called in a report: the method and the template, as above.
520    #[must_use]
521    pub fn name(&self) -> &str {
522        &self.name
523    }
524}
525
526/// Whether `path` is spelled like one of GitHub's documented endpoint templates.
527///
528/// An absolute path of `/`-separated segments, each a literal of the bytes a documented
529/// endpoint uses or a single `{placeholder}`. A host, a query string, a fragment, an empty
530/// segment and anything over the length GitHub's own longest endpoint needs are all refused,
531/// because each of them is a way an addressed URL — which carries what a run touched —
532/// differs from the template it was built from.
533fn is_path_template(path: &str) -> bool {
534    if path.is_empty() || path.len() > 128 || !path.starts_with('/') {
535        return false;
536    }
537    path[1..].split('/').all(|segment| {
538        let placeholder = segment
539            .strip_prefix('{')
540            .and_then(|rest| rest.strip_suffix('}'));
541        match placeholder {
542            Some(name) => {
543                !name.is_empty()
544                    && name
545                        .bytes()
546                        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
547            }
548            None => {
549                !segment.is_empty()
550                    && segment.bytes().all(|byte| {
551                        byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'.'
552                    })
553            }
554        }
555    })
556}
557
558/// What one request asked for.
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub enum Call {
561    /// A GraphQL request, which sends a document.
562    Document {
563        /// What the sender was doing, from
564        /// [`graphql::DOCUMENTS`] when the document is one of
565        /// this source's own, and from the caller's own name when it is not.
566        name: String,
567        /// The document's worst-case node count under the bindings that request sent, or
568        /// `None` when the calculation could not rule on the document — which is a defect
569        /// in the document rather than a cost of zero.
570        node_count: Option<u64>,
571    },
572    /// A REST request, which sends no document and has no node count.
573    Endpoint {
574        /// The endpoint it addressed. An [`Endpoint`] rather than a string, so a record
575        /// cannot hold the URL a run built — see that type for what it rules on.
576        endpoint: Endpoint,
577    },
578}
579
580impl Call {
581    /// What this call is called in a report.
582    #[must_use]
583    pub fn name(&self) -> &str {
584        match self {
585            Self::Document { name, .. } => name,
586            Self::Endpoint { endpoint } => endpoint.name(),
587        }
588    }
589    /// The budget it draws on.
590    #[must_use]
591    pub const fn budget(&self) -> Budget {
592        match self {
593            Self::Document { .. } => Budget::Graphql,
594            Self::Endpoint { .. } => Budget::Rest,
595        }
596    }
597    /// The most nodes it may return, for a GraphQL request that has such a number.
598    #[must_use]
599    pub const fn node_count(&self) -> Option<u64> {
600        match self {
601            Self::Document { node_count, .. } => *node_count,
602            Self::Endpoint { .. } => None,
603        }
604    }
605}
606
607/// One outgoing HTTP request, described before it is sent.
608///
609/// [`Sending::finished`], and [`Sending::answered`] for the ordinary case, are what turn it
610/// into a [`Request`]: a record with no outcome is one nobody could add up, so the outcome
611/// is the step that produces the record rather than a field that might be missing.
612///
613/// Everything a request carries before it is sent is settled by the constructor that made
614/// it, which is why there is no builder step here that could attach a reported GraphQL cost
615/// to a REST call — a budget metered in requests has no such figure, and GitHub reports
616/// none for one.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct Sending {
619    call: Call,
620    mode: Mode,
621    drawn: Drawn,
622    document: Option<String>,
623}
624
625/// Result at the protocol boundary, independent of the legacy retry classification.
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum ResponseOutcome {
628    /// A successful HTTP and GraphQL response.
629    Success,
630    /// A GraphQL error, including one carried by HTTP 200.
631    GraphqlError,
632    /// An unsuccessful HTTP status.
633    HttpError,
634    /// No readable protocol response.
635    TransportError,
636}
637
638impl ResponseOutcome {
639    /// Classify the real HTTP response and GraphQL envelope before recording success.
640    #[must_use]
641    pub fn of_graphql_reply(
642        status: reqwest::StatusCode,
643        reply: Option<&serde_json::Value>,
644    ) -> Self {
645        use serde_json::Value;
646        if !status.is_success() {
647            return Self::HttpError;
648        }
649        let Some(reply) = reply.filter(|reply| reply.is_object()) else {
650            return Self::TransportError;
651        };
652        if let Some(errors) = reply.get("errors")
653            && !errors.is_null()
654            && !errors.as_array().is_some_and(Vec::is_empty)
655        {
656            return if errors.as_array().is_some_and(|errors| {
657                errors
658                    .iter()
659                    .all(|error| error.get("message").is_some_and(Value::is_string))
660            }) {
661                Self::GraphqlError
662            } else {
663                Self::TransportError
664            };
665        }
666        if reply.get("data").is_some_and(Value::is_object) {
667            Self::Success
668        } else {
669            Self::TransportError
670        }
671    }
672}
673
674/// What a request draws on, and what only that kind of request can carry.
675///
676/// The reported cost lives inside the GraphQL arm rather than beside the call, because a
677/// REST request has nowhere to have got one from: GitHub meters that budget in requests and
678/// reports no per-call figure at all.
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680enum Drawn {
681    /// The GraphQL budget, with GitHub's own reported cost for this call when the request
682    /// was shaped so GitHub reported one.
683    Graphql { reported_cost: Option<u64> },
684    /// The REST budget, which a call is its own measure of.
685    Rest,
686}
687
688impl Sending {
689    /// GitHub answered it, with the rate-limit facts its response's headers carried.
690    #[must_use]
691    pub fn answered(self, limits: RateLimit) -> Request {
692        self.finished(Outcome::Answered, limits)
693    }
694    /// However it ended, with the rate-limit facts its response's headers carried.
695    #[must_use]
696    pub fn finished(self, outcome: Outcome, limits: RateLimit) -> Request {
697        let spend = match (self.drawn, outcome) {
698            (_, Outcome::RateLimited) => Spend::not_run(),
699            (Drawn::Rest, _) => Spend::counted(),
700            (
701                Drawn::Graphql {
702                    reported_cost: Some(cost),
703                },
704                _,
705            ) => Spend::reported(cost),
706            (
707                Drawn::Graphql {
708                    reported_cost: None,
709                },
710                _,
711            ) => Spend::modelled(),
712        };
713        Request {
714            call: self.call,
715            mode: self.mode,
716            outcome,
717            limits,
718            spend,
719            document: self.document,
720            reported_query_cost: match self.drawn {
721                Drawn::Graphql { reported_cost } => reported_cost,
722                Drawn::Rest => None,
723            },
724            response_outcome: if outcome == Outcome::Answered {
725                ResponseOutcome::Success
726            } else {
727                ResponseOutcome::TransportError
728            },
729            attempt: 1,
730        }
731    }
732}
733
734/// What a GraphQL document this crate does not send is called until its sender names it.
735const UNNAMED_DOCUMENT: &str = "talking to GitHub";
736
737/// One outgoing HTTP request, and what it cost.
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct Request {
740    call: Call,
741    mode: Mode,
742    outcome: Outcome,
743    limits: RateLimit,
744    spend: Spend,
745    document: Option<String>,
746    reported_query_cost: Option<u64>,
747    response_outcome: ResponseOutcome,
748    attempt: u32,
749}
750
751impl Request {
752    /// Begin recording one GraphQL request carrying `document` under `variables`.
753    ///
754    /// The name comes from [`graphql::DOCUMENTS`] — the
755    /// inventory, not a second list — and the node count from
756    /// [`node_count`] under the page sizes `variables` really binds, so
757    /// a page read with a caller's smaller limit is counted at that limit rather than at
758    /// the worst case. Any page-size variable the request leaves unbound keeps the largest
759    /// value this source could send it, which is the worst case for exactly that document.
760    ///
761    /// `otherwise` names a document the inventory does not hold, which is how a caller's own
762    /// calls — a schema introspection, a residue sweep — are named beside this source's;
763    /// `None` leaves such a document under the placeholder, and a document the inventory
764    /// does hold keeps its entry's name either way.
765    ///
766    /// `reported_cost` is GitHub's own `cost` for **this** call, from a response to a
767    /// request shaped to report one. A `rateLimit(dryRun: true)` probe reports what some
768    /// other document would cost and never what this call spent, so its figure does not
769    /// belong here.
770    #[must_use]
771    pub fn graphql(
772        document: &str,
773        variables: &Value,
774        otherwise: Option<&str>,
775        reported_cost: Option<u64>,
776    ) -> Sending {
777        let name = document_name(document);
778        Sending {
779            call: Call::Document {
780                name: match (name, otherwise) {
781                    (UNNAMED_DOCUMENT, Some(otherwise)) => otherwise.to_owned(),
782                    (name, _) => name.to_owned(),
783                },
784                // llmlint: ignore[invalid_states_unrepresentable] `None` here is not a missing invariant, it is the honest reading of a document this calculation could not rule on — and the state is reachable, because this constructor is public and `otherwise` exists for a caller's OWN documents (a schema introspection, a residue sweep) which no test of this source's inventory sweeps. Refusing to build the record would lose the request from the accounting entirely, which is strictly worse than recording a call whose node count is unknown. It is never totalled as zero: `Session::total_node_count` filter-maps it out, the report prints the total "over N GraphQL requests that have one", and the by-document section lists only those. Every document this source itself sends is held countable by `tests/node_count.rs`.
785                node_count: node_count(document, &bindings(variables)).ok(),
786            },
787            mode: Mode::of_document(document),
788            drawn: Drawn::Graphql { reported_cost },
789            document: Some(document.to_owned()),
790        }
791    }
792    /// Begin recording one REST request against `endpoint`.
793    ///
794    /// `endpoint` is the endpoint rather than the URL that was built from it — `GET
795    /// /repos/{owner}/{repo}/labels`, not the repository and label a run happened to name —
796    /// because a report is compared between runs and carries no board content. It is an
797    /// [`Endpoint`] rather than a string for that reason: what a report may hold is settled
798    /// where the endpoint is written, not here.
799    #[must_use]
800    pub fn rest(endpoint: Endpoint) -> Sending {
801        Sending {
802            mode: endpoint.method().mode(),
803            call: Call::Endpoint { endpoint },
804            drawn: Drawn::Rest,
805            document: None,
806        }
807    }
808    /// What was called.
809    #[must_use]
810    pub const fn call(&self) -> &Call {
811        &self.call
812    }
813    /// What it is called in a report.
814    #[must_use]
815    pub fn name(&self) -> &str {
816        self.call.name()
817    }
818    /// Whether it read or wrote.
819    #[must_use]
820    pub const fn mode(&self) -> Mode {
821        self.mode
822    }
823    /// How it ended.
824    #[must_use]
825    pub const fn outcome(&self) -> Outcome {
826        self.outcome
827    }
828    /// The budget it drew on.
829    #[must_use]
830    pub const fn budget(&self) -> Budget {
831        self.call.budget()
832    }
833    /// The most nodes it may return, for a GraphQL request that has such a number.
834    #[must_use]
835    pub const fn node_count(&self) -> Option<u64> {
836        self.call.node_count()
837    }
838    /// What it is attributed against its budget, and where that figure came from.
839    #[must_use]
840    pub const fn spend(&self) -> Spend {
841        self.spend
842    }
843    /// The rate-limit facts this response's own headers carried.
844    #[must_use]
845    pub const fn rate_limit(&self) -> &RateLimit {
846        &self.limits
847    }
848    /// Operation document alone; variables and response bodies are never retained here.
849    #[must_use]
850    pub fn document(&self) -> Option<&str> {
851        self.document.as_deref()
852    }
853    /// The actual response cost, without substituting the legacy ledger's model.
854    #[must_use]
855    pub const fn reported_query_cost(&self) -> Option<u64> {
856        self.reported_query_cost
857    }
858    /// Exact protocol result for live-session accounting.
859    #[must_use]
860    pub const fn response_outcome(&self) -> ResponseOutcome {
861        self.response_outcome
862    }
863    /// Attempt number for this dispatch.
864    #[must_use]
865    pub const fn attempt(&self) -> u32 {
866        self.attempt
867    }
868    /// Attach facts known only to the actual transport, preserving legacy ledger readers.
869    #[must_use]
870    pub fn with_execution(
871        mut self,
872        document: Option<String>,
873        outcome: ResponseOutcome,
874        attempt: u32,
875    ) -> Self {
876        self.document = document;
877        self.response_outcome = outcome;
878        self.attempt = attempt;
879        self
880    }
881}
882
883/// What this source calls the document it just sent, from the inventory rather than a copy.
884fn document_name(document: &str) -> &str {
885    graphql::DOCUMENTS
886        .iter()
887        .find(|(known, _)| *known == document)
888        .map_or(UNNAMED_DOCUMENT, |(_, doing)| *doing)
889}
890
891/// The page sizes one request really bound, over the largest this source can send.
892///
893/// Starting from [`largest_page_sizes`] rather than from nothing is what keeps a document
894/// countable when the request leaves one of its page-size variables out: the calculation
895/// refuses a `first:` it has no binding for, and the worst case is the honest answer for a
896/// size nobody narrowed. A variable no `first:` references — a project number, a page
897/// cursor — is ignored by the calculation, so passing every integer through costs nothing.
898fn bindings(variables: &Value) -> Variables {
899    let mut bindings = largest_page_sizes();
900    if let Some(bound) = variables.as_object() {
901        for (name, value) in bound {
902            if let Some(size) = value.as_u64().and_then(|size| u32::try_from(size).ok()) {
903                bindings.insert(name.clone(), size);
904            }
905        }
906    }
907    bindings
908}
909
910/// Which attachment request GitHub answered.
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912pub enum AttachmentOperation {
913    /// The content-creating upload.
914    Upload,
915    /// An authenticated attachment read.
916    Read,
917}
918
919/// One attachment response, retained inside the owning plugin for live verification reports.
920#[derive(Debug, Clone)]
921pub struct AttachmentResponse {
922    /// The referenced asset's validated name.
923    pub name: onetaskgraph_plugin_api::AssetName,
924    /// The endpoint GitHub answered.
925    pub url: reqwest::Url,
926    /// The status GitHub actually returned.
927    pub status: reqwest::StatusCode,
928    /// Whether this was the content-creating upload rather than a read.
929    pub operation: AttachmentOperation,
930}
931
932/// Every request one session sent, and what each cost.
933///
934/// It is on this crate's ordinary code path — [`crate::GitHubProjectsSource`] records into
935/// one at the single place a request leaves the crate, with no environment variable, no
936/// feature and no build configuration to know about, because an instrument nobody switches
937/// on measures nothing. It is constructible and recordable-into from outside the crate for
938/// the other half of the same reason: a caller making its own calls beside this source's —
939/// the credentialed lane verifying a schema, sweeping residue, cleaning up — accounts for
940/// the whole session rather than for this source's share of it.
941#[derive(Debug, Default)]
942pub struct Accounting {
943    requests: Mutex<Vec<Request>>,
944    attachment_responses: Mutex<Vec<AttachmentResponse>>,
945    estimates: Mutex<BTreeMap<Budget, u64>>,
946    query_prices: bool,
947}
948
949impl Accounting {
950    /// An accounting with nothing in it.
951    #[must_use]
952    pub fn new() -> Self {
953        Self::default()
954    }
955    /// Record actual query prices by selecting `rateLimit { cost }` on outgoing reads.
956    /// Existing ledger readers and default request documents retain their behavior.
957    #[must_use]
958    pub fn with_query_prices() -> Self {
959        Self {
960            query_prices: true,
961            ..Self::default()
962        }
963    }
964    /// Select a read's actual cost, preserving mutation documents and existing probe aliases.
965    #[must_use]
966    pub fn priced_document<'a>(&self, document: &'a str) -> std::borrow::Cow<'a, str> {
967        if !self.query_prices
968            || Mode::of_document(document) == Mode::Write
969            || document.contains("dryRun:true")
970            || document.contains("rateLimit{cost")
971        {
972            return std::borrow::Cow::Borrowed(document);
973        }
974        match document.find('{') {
975            Some(root) => std::borrow::Cow::Owned(format!(
976                "{}rateLimit{{cost}} {}",
977                &document[..=root],
978                &document[root + 1..]
979            )),
980            None => std::borrow::Cow::Borrowed(document),
981        }
982    }
983    /// Record what a caller estimated this session would spend against `budget`, before
984    /// it started.
985    ///
986    /// An estimate is not an observation and is kept apart from one everywhere below: it is
987    /// what a precondition decided on, and the point of carrying it here is that a report
988    /// can put it beside what the session really spent, so a reader sees how far the model
989    /// was from GitHub's own figures rather than being told to trust it. The last estimate
990    /// recorded for a budget is the one reported, because a precondition decides once.
991    pub fn estimate(&self, budget: Budget, cost: u64) {
992        self.estimates
993            .lock()
994            .unwrap_or_else(std::sync::PoisonError::into_inner)
995            .insert(budget, cost);
996    }
997    /// Record one request.
998    pub fn record(&self, request: Request) {
999        // A poisoned lock costs the accounting, never the work: a panic elsewhere must not
1000        // turn measuring the session into a second failure on top of the first.
1001        self.requests
1002            .lock()
1003            .unwrap_or_else(std::sync::PoisonError::into_inner)
1004            .push(request);
1005    }
1006    pub(crate) fn record_attachment(&self, response: AttachmentResponse) {
1007        self.attachment_responses
1008            .lock()
1009            .unwrap_or_else(std::sync::PoisonError::into_inner)
1010            .push(response);
1011    }
1012    /// The actual upload and read statuses, for a caller verifying a credentialed copy.
1013    #[must_use]
1014    pub fn attachment_responses(&self) -> Vec<AttachmentResponse> {
1015        self.attachment_responses
1016            .lock()
1017            .unwrap_or_else(std::sync::PoisonError::into_inner)
1018            .clone()
1019    }
1020    /// A snapshot of what has been recorded so far: a value to hold and compare, never a
1021    /// live borrow of this accounting.
1022    #[must_use]
1023    pub fn snapshot(&self) -> Session {
1024        Session {
1025            requests: self
1026                .requests
1027                .lock()
1028                .unwrap_or_else(std::sync::PoisonError::into_inner)
1029                .clone(),
1030            estimates: self
1031                .estimates
1032                .lock()
1033                .unwrap_or_else(std::sync::PoisonError::into_inner)
1034                .clone(),
1035        }
1036    }
1037}
1038
1039/// One session's requests, as a value a caller can hold, compare and report on.
1040#[derive(Debug, Clone, Default, PartialEq, Eq)]
1041pub struct Session {
1042    requests: Vec<Request>,
1043    estimates: BTreeMap<Budget, u64>,
1044}
1045
1046impl Session {
1047    /// The requests it holds, in the order they were recorded.
1048    #[must_use]
1049    pub fn requests(&self) -> &[Request] {
1050        &self.requests
1051    }
1052    /// How many requests this session sent.
1053    #[must_use]
1054    pub fn total_requests(&self) -> usize {
1055        self.requests.len()
1056    }
1057    /// The nodes every GraphQL request that has a node count may return, added up.
1058    #[must_use]
1059    pub fn total_node_count(&self) -> u64 {
1060        self.requests
1061            .iter()
1062            .filter_map(Request::node_count)
1063            .fold(0, u64::saturating_add)
1064    }
1065    /// What a precondition estimated this session would spend against `budget`, before it
1066    /// started, when one estimated anything at all.
1067    #[must_use]
1068    pub fn estimated(&self, budget: Budget) -> Option<u64> {
1069        self.estimates.get(&budget).copied()
1070    }
1071    /// What this session is **attributed** against `budget`, summed per call.
1072    ///
1073    /// Attributed rather than spent, and the difference is the point: only a call GitHub
1074    /// itself reported a cost for is a measurement. A call against a budget metered in
1075    /// requests is its own measure, and every other GraphQL call carries [`Basis::Modelled`]
1076    /// — one point, GitHub's documented minimum, which is a **lower bound**. So this is what
1077    /// the session can account for, never a claim about what GitHub charged; [`Self::budgets`]
1078    /// is where the same total comes apart by basis, and that is what says how much of it is
1079    /// measured.
1080    #[must_use]
1081    pub fn attributed(&self, budget: Budget) -> u64 {
1082        self.requests
1083            .iter()
1084            .filter(|request| request.budget() == budget)
1085            .fold(0, |total, request| {
1086                total.saturating_add(request.spend.amount)
1087            })
1088    }
1089    /// Every budget this session touched, in a stable order.
1090    #[must_use]
1091    pub fn budgets(&self) -> Vec<BudgetReport> {
1092        let mut touched: BTreeMap<Budget, BudgetReport> = BTreeMap::new();
1093        // Seeded from the estimates first: a budget a precondition sized this session
1094        // against and the session then never reached is worth reporting as exactly that,
1095        // rather than vanishing from the report that is supposed to compare the two.
1096        for (budget, estimated) in &self.estimates {
1097            touched
1098                .entry(*budget)
1099                .or_insert_with(|| BudgetReport::of(*budget))
1100                .estimated = Some(*estimated);
1101        }
1102        for request in &self.requests {
1103            let budget = request.budget();
1104            touched
1105                .entry(budget)
1106                .or_insert_with(|| BudgetReport::of(budget))
1107                .record(request);
1108        }
1109        touched.into_values().collect()
1110    }
1111    /// This session as the plugin interface's [`Metering`]: every request it holds, and
1112    /// what each of GitHub's two budgets was attributed, split by basis.
1113    ///
1114    /// Both budgets are always named, a budget this session never drew on at zero, so a
1115    /// reading taken before a session's first request lines up with one taken after it.
1116    /// What [`Basis::Modelled`] attributed is `modelled` and everything else is `measured`
1117    /// — a request a rate limiter refused is attributed nothing either way.
1118    #[must_use]
1119    pub fn metering(&self) -> Metering {
1120        Metering {
1121            requests: u64::try_from(self.total_requests()).unwrap_or(u64::MAX),
1122            budgets: [Budget::Graphql, Budget::Rest]
1123                .into_iter()
1124                .map(|budget| {
1125                    let (measured, modelled) = self
1126                        .requests
1127                        .iter()
1128                        .filter(|request| request.budget() == budget)
1129                        .fold(
1130                            (0_u64, 0_u64),
1131                            |(measured, modelled), request| match request.spend.basis {
1132                                Basis::Modelled => {
1133                                    (measured, modelled.saturating_add(request.spend.amount))
1134                                }
1135                                _ => (measured.saturating_add(request.spend.amount), modelled),
1136                            },
1137                        );
1138                    Metered {
1139                        budget: budget.name().to_owned(),
1140                        unit: budget.unit().to_owned(),
1141                        measured,
1142                        modelled,
1143                    }
1144                })
1145                .collect(),
1146        }
1147    }
1148    /// The session report: what a person puts two runs of side by side.
1149    #[must_use]
1150    pub fn report(&self) -> String {
1151        let mut report = String::from("github-projects session accounting\n");
1152        let count = |mode: Mode| {
1153            self.requests
1154                .iter()
1155                .filter(|request| request.mode == mode)
1156                .count()
1157        };
1158        let ended = |outcome: Outcome| {
1159            self.requests
1160                .iter()
1161                .filter(|request| request.outcome == outcome)
1162                .count()
1163        };
1164        let _ = writeln!(
1165            report,
1166            "requests {}: {} {}, {} {}; {} {}, {} {}, {} {}",
1167            self.total_requests(),
1168            count(Mode::Read),
1169            Mode::Read.name(),
1170            count(Mode::Write),
1171            Mode::Write.name(),
1172            ended(Outcome::Answered),
1173            Outcome::Answered.name(),
1174            ended(Outcome::Refused),
1175            Outcome::Refused.name(),
1176            ended(Outcome::RateLimited),
1177            Outcome::RateLimited.name(),
1178        );
1179        report.push_str("requests by call\n");
1180        let mut by_call: BTreeMap<&str, (usize, u64, usize)> = BTreeMap::new();
1181        for request in &self.requests {
1182            let entry = by_call.entry(request.name()).or_insert((0, 0, 0));
1183            entry.0 += 1;
1184            if let Some(nodes) = request.node_count() {
1185                entry.1 = entry.1.saturating_add(nodes);
1186                entry.2 += 1;
1187            }
1188        }
1189        for (name, (requests, _, _)) in &by_call {
1190            let _ = writeln!(report, "  {name:<52}{requests:>6}");
1191        }
1192        let counted = self
1193            .requests
1194            .iter()
1195            .filter(|request| request.node_count().is_some())
1196            .count();
1197        let _ = writeln!(
1198            report,
1199            "node count {} over {counted} GraphQL requests that have one",
1200            self.total_node_count(),
1201        );
1202        report.push_str("node count by document\n");
1203        for (name, (_, nodes, requests)) in by_call.iter().filter(|(_, (_, _, with))| *with > 0) {
1204            let counted = format!(
1205                "{requests} {}",
1206                if *requests == 1 {
1207                    "request"
1208                } else {
1209                    "requests"
1210                }
1211            );
1212            let _ = writeln!(report, "  {name:<52}{counted:>14}{nodes:>12} nodes");
1213        }
1214        for budget in self.budgets() {
1215            report.push_str(&budget.render());
1216        }
1217        report
1218    }
1219}
1220
1221/// One budget a session drew on, with its own figures kept apart from the account's.
1222///
1223/// **Every figure here is a total over requests that really drew on this budget, so the only
1224/// way to have one is to add those requests up.** [`Session::budgets`] is that, and
1225/// [`BudgetReport::record`] is where a request joins one: the request count, the spend, its
1226/// three attributions and the account's own readings all move together, from the same record,
1227/// so a report cannot say it summarises nine requests while its attributions add up to four,
1228/// or carry a REST budget's figures under [`Budget::Graphql`]. The fields are read through
1229/// the accessors below for exactly the reason [`RateLimit`]'s are: a hand-assembled set of
1230/// totals would be a measurement of nothing, and this whole accounting exists because a
1231/// number nobody measured was argued about instead.
1232#[derive(Debug, Clone, PartialEq, Eq)]
1233pub struct BudgetReport {
1234    budget: Budget,
1235    estimated: Option<u64>,
1236    requests: usize,
1237    attributed: u64,
1238    reported: u64,
1239    modelled: u64,
1240    counted: u64,
1241    not_run: usize,
1242    limit: Option<u64>,
1243    used_by_the_account: Option<u64>,
1244    remaining_first_seen: Option<u64>,
1245    remaining_last_seen: Option<u64>,
1246}
1247
1248impl BudgetReport {
1249    /// A report of `budget` with nothing added to it yet.
1250    const fn of(budget: Budget) -> Self {
1251        Self {
1252            budget,
1253            estimated: None,
1254            requests: 0,
1255            attributed: 0,
1256            reported: 0,
1257            modelled: 0,
1258            counted: 0,
1259            not_run: 0,
1260            limit: None,
1261            used_by_the_account: None,
1262            remaining_first_seen: None,
1263            remaining_last_seen: None,
1264        }
1265    }
1266    /// Add one of this budget's requests, and everything its response said about it.
1267    ///
1268    /// The one place these totals move, which is what makes them agree with each other and
1269    /// with the requests they are over.
1270    fn record(&mut self, request: &Request) {
1271        self.requests += 1;
1272        self.attributed = self.attributed.saturating_add(request.spend.amount);
1273        match request.spend.basis {
1274            Basis::Reported => {
1275                self.reported = self.reported.saturating_add(request.spend.amount);
1276            }
1277            Basis::Modelled => {
1278                self.modelled = self.modelled.saturating_add(request.spend.amount);
1279            }
1280            Basis::Counted => {
1281                self.counted = self.counted.saturating_add(request.spend.amount);
1282            }
1283            // Attributed nothing, and counted as one of the requests that ran into the
1284            // limiter. Its headers are still read below — a refusal for a spent budget is
1285            // the response whose figures say most about that budget's state.
1286            Basis::NotRun => self.not_run += 1,
1287        }
1288        if let Some(limit) = request.limits.limit() {
1289            self.limit = Some(limit);
1290        }
1291        if let Some(used) = request.limits.used_by_the_account() {
1292            self.used_by_the_account = Some(used);
1293        }
1294        if let Some(remaining) = request.limits.remaining() {
1295            self.remaining_first_seen.get_or_insert(remaining);
1296            self.remaining_last_seen = Some(remaining);
1297        }
1298    }
1299    /// Which budget.
1300    #[must_use]
1301    pub const fn budget(&self) -> Budget {
1302        self.budget
1303    }
1304    /// What a precondition estimated this session would spend against it before it
1305    /// started, when one estimated anything at all.
1306    ///
1307    /// It is an estimate rather than a measurement, and the report says so where it prints
1308    /// it: what makes it worth carrying is that a reader can see how far the model was from
1309    /// what the session really spent.
1310    #[must_use]
1311    pub const fn estimated(&self) -> Option<u64> {
1312        self.estimated
1313    }
1314    /// How many of this session's requests drew on it.
1315    #[must_use]
1316    pub const fn requests(&self) -> usize {
1317        self.requests
1318    }
1319    /// What this session itself is **attributed** against it, summed per call.
1320    ///
1321    /// Attributed rather than spent, for the reason [`Session::attributed`] gives: some of
1322    /// this figure is [`Basis::Modelled`], GitHub's documented one-point minimum, which is a
1323    /// lower bound and not a measurement. The four figures below are the same total split by
1324    /// basis, which is what says how much of it GitHub itself reported.
1325    #[must_use]
1326    pub const fn attributed(&self) -> u64 {
1327        self.attributed
1328    }
1329    /// How much of that GitHub itself reported.
1330    #[must_use]
1331    pub const fn reported(&self) -> u64 {
1332        self.reported
1333    }
1334    /// How much of it is this repository's one-point-per-call lower bound.
1335    #[must_use]
1336    pub const fn modelled(&self) -> u64 {
1337        self.modelled
1338    }
1339    /// How much of it is a count of requests against a budget metered in requests.
1340    #[must_use]
1341    pub const fn counted(&self) -> u64 {
1342        self.counted
1343    }
1344    /// How many of its requests a rate limiter refused, so they never ran and are
1345    /// attributed nothing.
1346    #[must_use]
1347    pub const fn not_run(&self) -> usize {
1348        self.not_run
1349    }
1350    /// The whole allowance, as GitHub's own headers reported it.
1351    #[must_use]
1352    pub const fn limit(&self) -> Option<u64> {
1353        self.limit
1354    }
1355    /// What the **account** had spent, as GitHub's own headers reported it. Not this
1356    /// session's spend: other work draws on the same budget in the same window.
1357    #[must_use]
1358    pub const fn used_by_the_account(&self) -> Option<u64> {
1359        self.used_by_the_account
1360    }
1361    /// The allowance remaining when this session's first request against this budget was
1362    /// answered.
1363    #[must_use]
1364    pub const fn remaining_first_seen(&self) -> Option<u64> {
1365        self.remaining_first_seen
1366    }
1367    /// The allowance remaining when its last one was.
1368    #[must_use]
1369    pub const fn remaining_last_seen(&self) -> Option<u64> {
1370        self.remaining_last_seen
1371    }
1372    /// How far the **account's** remaining allowance fell while this session ran.
1373    ///
1374    /// A fall rather than a movement, and the difference is not pedantry: an allowance that
1375    /// *rose* is the hourly window having reset mid-session, which is not a negative spend
1376    /// and answers zero here. Either way it is not this session's spend, and the report says
1377    /// so where it prints it — this account is shared, so the difference between two
1378    /// readings of a shared counter measures the account.
1379    #[must_use]
1380    pub fn account_allowance_fall(&self) -> Option<u64> {
1381        Some(
1382            self.remaining_first_seen?
1383                .saturating_sub(self.remaining_last_seen?),
1384        )
1385    }
1386    /// This budget's lines of the session report.
1387    #[must_use]
1388    pub fn render(&self) -> String {
1389        let unknown = |value: Option<u64>| {
1390            value.map_or_else(|| "not reported".to_owned(), |value| value.to_string())
1391        };
1392        let mut lines = format!(
1393            "budget {}, metered in {}\n",
1394            self.budget.name(),
1395            self.budget.unit()
1396        );
1397        let _ = writeln!(
1398            lines,
1399            "  this session sent {} requests and is attributed {} {} — measured only where \
1400             GitHub reported it: {} {}, {} {}, {} {}; {} {}",
1401            self.requests,
1402            self.attributed,
1403            self.budget.unit(),
1404            self.reported,
1405            Basis::Reported.name(),
1406            self.modelled,
1407            Basis::Modelled.name(),
1408            self.counted,
1409            Basis::Counted.name(),
1410            self.not_run,
1411            Basis::NotRun.name(),
1412        );
1413        if let Some(estimated) = self.estimated {
1414            let _ = writeln!(
1415                lines,
1416                "  a precondition estimated {estimated} {} before this session started, \
1417                 against the {} attributed above",
1418                self.budget.unit(),
1419                self.attributed,
1420            );
1421        }
1422        let _ = writeln!(
1423            lines,
1424            "  the account: limit {}, {} used, {} remaining when this session finished",
1425            unknown(self.limit),
1426            unknown(self.used_by_the_account),
1427            unknown(self.remaining_last_seen),
1428        );
1429        let _ = writeln!(
1430            lines,
1431            "  the account's remaining allowance fell {} while this session ran; that is the \
1432             account's own consumption and not this session's spend, because other work \
1433             draws on the same budget in the same window",
1434            unknown(self.account_allowance_fall()),
1435        );
1436        lines
1437    }
1438}