Skip to main content

git_policy_cli/
service_work.rs

1//! What:
2//!  The service's consumer of after-commit work: pending `push` and `maintenance` work items of one repository are
3//!  claimed, coalesced, run, settled, and a push that failed leaves a record that the person's next wrapper command shows.
4//! Why:
5//!  A commit must not wait for the network or for upkeep, so the wrapper leaves a record and the resident service does
6//!  the work on its own thread (`doc/planning/cli-git-resident-service.md`, "After-commit work"). The service is the
7//!  only pusher of the repositories it serves, so no lock is needed: several pending pushes of one branch become one
8//!  push, and a service that died mid-push is noticed by the lease of its claims running out, never by a process id: the
9//!  holder renews the lease while it works, so a successor that starts beside a holder that is still running leaves its claims
10//!  alone, and one that starts after the holder died takes them back as soon as the lease is over. Other kinds of work
11//!  (the standards review) are never claimed here.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! for (const group of coalesce(await claimAll(workDir, ['push', 'maintenance']))) settle(group, await handler(group));
16//! ```
17
18use super::auto_push::push_once;
19use super::auto_push_single_flight::PushGit;
20use super::config_snapshot::read_config_snapshot;
21use super::lfs_pre_push::uses_lfs;
22use super::maintenance_run::{AutoMaintenanceOutcome, run_auto_maintenance_with};
23use super::auto_push::AutoPushOutcome;
24use super::claim_time::ClaimTime;
25use super::push_failure_notice::{clear_push_failures_before, record_push_failure, record_push_skip};
26use super::repo_read_config_text::{last_entry, parse_bool, parse_config_text};
27use super::service_claim_renewal::{ClaimRenewal, HeldClaims, RENEWALS_PER_LEASE};
28use super::service_review::{BUDGET, options_for, review_item, review_variables};
29use super::service_review_key::ReviewKey;
30use super::standards_review_run::ReviewOptions;
31use super::transaction_git::GitContext;
32use super::work_item::{Claimed, DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, next_claim_expiry, requeue_expired_reporting};
33use std::path::{Path, PathBuf};
34use std::time::Duration;
35
36/// The kinds of work the service runs itself.
37pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
38
39/// How many times an item is tried before it is given up on.
40pub const MAX_TRIES: u32 = 3;
41
42/// What a wake message brings: the repository to work on and the review credential that came with it, if any.
43pub type Wake = (PathBuf, Option<ReviewKey>);
44
45/// What a handler came to.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum Verdict {
48    /// The work was done, or there was nothing to do.
49    Done,
50    /// The work failed; the text is what the person should be told.
51    Failed(String),
52    /// Nothing was done and nothing is left to retry, but the person should be told why; the text is what they are told.
53    Skipped(String),
54}
55
56/// What runs each kind of work.
57pub trait WorkHandlers {
58    /// Push the branch of these items, which share a repository and a branch; the last is the newest.
59    fn push(&self, items: &[WorkItem]) -> Verdict;
60    /// Run the repository's automatic upkeep.
61    fn maintenance(&self, item: &WorkItem) -> Verdict;
62    /// Review one landed commit into the repository's shadow log.
63    fn review(&self, item: &WorkItem) -> Verdict;
64}
65
66/// The work directory of a repository.
67pub fn work_directory(git_dir: &Path) -> PathBuf {
68    return git_dir.join(WORK_DIRECTORY);
69}
70
71/// What:
72///  Whether an item is one this service should act on for this repository.
73/// Why:
74///   Anyone who can write the work directory can write an item, so the fields that become a directory, a reference or a
75///  program argument are checked before they are used.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// isAcceptable(item, gitDir)
80/// ```
81pub fn is_acceptable(item: &WorkItem, git_dir: &Path) -> bool {
82    return Path::new(item.repository.as_str()).is_absolute()
83        && Path::new(item.repository.as_str()).is_dir()
84        && Path::new(item.git_dir.as_str()) == git_dir
85        && item.branch_ref.starts_with("refs/")
86        && !item.branch_ref.contains(['\0', '\n', ' '])
87        && item.oid.len() >= 40
88        && item.oid.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
89}
90
91/// Moves the claims to `state` and stops renewing them; a claim that another instance took over is said, and left to it.
92fn settle_all(work_dir: &Path, held: &HeldClaims, claimed: &[Claimed], state: &str) {
93    for item in claimed {
94        if let Err(reason) = held.settle(work_dir, item.id.as_str(), state) {
95            eprintln!("cli-git-service: {reason}");
96        }
97    }
98}
99
100/// What:
101///  The claims of `claimed` that this instance still holds, before their work starts.
102/// Why:
103///   A claim whose renewal found it gone was taken over: another instance runs that item now, so running it here as well would
104///  do it twice. The claim is forgotten and the loss said, and nothing else is done for it.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// claimed.filter((c) => held.isHeld(c.id))
109/// ```
110fn still_held(held: &HeldClaims, claimed: Vec<Claimed>) -> Vec<Claimed> {
111    let (mine, lost): (Vec<Claimed>, Vec<Claimed>) = claimed.into_iter().partition(|item| { return held.is_held(item.id.as_str()); });
112    for item in lost {
113        eprintln!(
114            "cli-git-service: the claim on {} ran out before this instance could run it, so another instance took the work over and it is not run here",
115            item.id
116        );
117        held.release(item.id.as_str());
118    }
119    return mine;
120}
121
122/// What:
123///  Whether a common Git directory belongs to a repository with no main worktree, which `core.bare` in its own `config`, or in its
124///  `config.worktree` when `extensions.worktreeConfig` turns that file on, says.
125/// Why:
126///   A bare repository has no instance whose Git directory is the common one, so the instances of its linked worktrees are the
127///  only ones that can run the records left there; a repository with a main worktree has that instance, which owns them.
128///   Git moves `core.bare` into `config.worktree` when the extension is enabled (`git help config`, "extensions.worktreeConfig"),
129///  and reads that file after `config` only while the extension is on and the repository has a format version
130///  (`repo_read_layout.rs`), so the last `core.bare` of the two decides. An unreadable or unparsable file, or a spelling this
131///  reader leaves to Git, reads as not bare, and the records then stay for the main worktree's instance, which is the safe side
132///  of a wrong answer.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// isBareCommonDirectory(commonDir)
137/// ```
138fn is_bare_common_directory(common: &Path) -> bool {
139    let Ok(text) = std::fs::read(common.join("config")) else {
140        return false;
141    };
142    let Some(mut entries) = parse_config_text(text.as_slice()) else {
143        return false;
144    };
145    let worktree_config: bool = last_entry(entries.as_slice(), "extensions.worktreeconfig").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); })
146        && last_entry(entries.as_slice(), "core.repositoryformatversion").is_some();
147    if worktree_config {
148        // A missing file is an empty file; one that is there and cannot be read or parsed leaves the answer to Git.
149        match std::fs::read(common.join("config.worktree")) {
150            Ok(bytes) => match parse_config_text(bytes.as_slice()) {
151                Some(more) => entries.extend(more),
152                None => return false,
153            },
154            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
155            Err(_) => return false,
156        }
157    }
158    return last_entry(entries.as_slice(), "core.bare").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); });
159}
160
161/// What:
162///  Leaves a notice for each push the service gave up on without ever finishing it: its holder was stopped, or lost, on every
163///  try.
164/// Why:
165///   Such an item moves to `failed` by recovery, not by a handler, so nothing else records why the commit was never backed up
166///  and the person would not be told. The notice is the one a skipped push leaves, because nothing failed and nothing was sent.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// for (const item of givenUp.filter(isPush)) await recordPushSkip(gitDir, item, reason)
171/// ```
172fn tell_about_given_up(git_dir: &Path, given_up: &[WorkItem]) {
173    for item in given_up.iter().filter(|item| { return item.kind == WorkKind::Push; }) {
174        let reason: String = format!(
175            "cli-git: the commit service stopped {MAX_TRIES} times before it finished pushing {} (a restart, a kill or a crash each time), so it gave up on that push.",
176            item.branch_ref
177        );
178        if let Err(problem) = record_push_skip(git_dir, item.branch_ref.as_str(), item.oid.as_str(), reason.as_str()) {
179            eprintln!("cli-git-service: the abandoned push of {} could not be recorded for the person: {problem}", item.oid);
180        }
181    }
182}
183
184/// How long a claim goes without a renewal before another instance runs its item again; the holder renews it three times in this
185/// span while it works (`service_claim_renewal.rs`), so a holder that is slow keeps its claim and one that died loses it
186/// after this long.
187pub const CLAIM_LEASE: Duration = Duration::from_secs(120);
188
189/// What:
190///  Runs everything pending for one repository: takes back the claims whose holder stopped renewing them, claims every pending
191///  push, maintenance and review item, runs one push per branch and one upkeep per repository, settles the items, and leaves a
192///  notice for each failed or skipped push. Gives how long until a claim another instance still holds runs out its lease, or
193///  nothing when there is none.
194/// Why:
195///   Coalescing keeps a burst of commits to one push; a handler that fails leaves the items in `failed`, not in a loop. A
196///  claim found in this directory may belong to a live instance (a restarted service starts beside the one it replaces, and the
197///  linked worktrees of a bare repository share a directory), so only its lease says it was abandoned; the caller is told when
198///  to look again, because nothing else would wake it once the lease is over.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// const wait = await drain(gitDir, handlers, readClaimTime())
203/// ```
204pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
205    return drain_with_lease(git_dir, handlers, now, CLAIM_LEASE);
206}
207
208/// `drain` with the lease given, so a test can use a short one.
209pub fn drain_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
210    let work_dir: PathBuf = work_directory(git_dir);
211    tell_about_given_up(git_dir, requeue_expired_reporting(work_dir.as_path(), now, lease, MAX_TRIES).given_up.as_slice());
212    drain_pending(git_dir, handlers, lease);
213    return next_claim_expiry(work_dir.as_path(), &ClaimTime::read(), lease);
214}
215
216/// What:
217///  Runs the records the releases before one service per worktree left in the common directory of a bare repository, from the
218///  instance of a linked worktree: nothing for a repository with a main worktree, for a main worktree and for a directory with
219///  no `commondir`.
220/// Why:
221///   Those releases kept every worktree's records in the common directory. A repository with a main worktree has an instance
222///  whose own directory that is, and it runs them (the main worktree's current records live there too), so a linked instance
223///  must not. A repository whose worktrees are all linked has no such instance, and its pushes would sit there with no notice.
224///   Several linked instances may drain the directory at once, so a claim is taken back only after its lease, which the holder
225///  renews while it works.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// const wait = await drainCommonRecords(linkedGitDir, handlers, now)
230/// ```
231pub fn drain_common_records(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
232    return drain_common_records_with_lease(git_dir, handlers, now, CLAIM_LEASE);
233}
234
235/// `drain_common_records` with the lease given, so a test can use a short one.
236pub fn drain_common_records_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
237    let common = common_directory_of(git_dir)?;
238    if common == git_dir || !is_bare_common_directory(common.as_path()) || !work_directory(common.as_path()).is_dir() {
239        return None;
240    }
241    tell_about_given_up(common.as_path(), requeue_expired_reporting(work_directory(common.as_path()).as_path(), now, lease, MAX_TRIES).given_up.as_slice());
242    drain_pending(common.as_path(), handlers, lease);
243    return next_claim_expiry(work_directory(common.as_path()).as_path(), &ClaimTime::read(), lease);
244}
245
246/// The common directory a linked worktree's Git directory names in its `commondir` file, or nothing for any other directory.
247fn common_directory_of(git_dir: &Path) -> Option<PathBuf> {
248    let text: String = std::fs::read_to_string(git_dir.join("commondir")).ok()?;
249    let named: &Path = Path::new(text.trim_end_matches(['\n', '\r']));
250    if named.as_os_str().is_empty() {
251        return None;
252    }
253    // Resolved by the text alone, so the result spells the path the way the worktree's own Git directory does, which is how the
254    //  records name their directory.
255    let mut resolved: PathBuf = PathBuf::new();
256    for part in git_dir.join(named).components() {
257        match part {
258            std::path::Component::ParentDir => {
259                if !resolved.pop() {
260                    return None;
261                }
262            }
263            std::path::Component::CurDir => {}
264            other => resolved.push(other.as_os_str()),
265        }
266    }
267    return Some(resolved);
268}
269
270/// Claims and runs every pending item of one work directory until none is left; the claims held are renewed every third of
271/// `lease`, so that none of them looks abandoned to another instance while it waits or runs.
272fn drain_pending(git_dir: &Path, handlers: &dyn WorkHandlers, lease: Duration) {
273    let work_dir: PathBuf = work_directory(git_dir);
274    let held: HeldClaims = HeldClaims::default();
275    let _renewal: ClaimRenewal = ClaimRenewal::start(work_dir.clone(), held.clone(), lease / RENEWALS_PER_LEASE);
276    loop {
277        let mut claimed: Vec<Claimed> = Vec::new();
278        loop {
279            // Each claim carries the reading of the moment it is taken, not of the start of the round, which a long round makes
280            //  most of a lease old.
281            match claim_next_of(work_dir.as_path(), &ClaimTime::read(), &SERVICE_KINDS) {
282                Ok(Some(item)) => {
283                    held.hold(&item);
284                    claimed.push(item);
285                }
286                Ok(None) => break,
287                Err(reason) => {
288                    eprintln!("cli-git-service: {reason}");
289                    break;
290                }
291            }
292        }
293        if claimed.is_empty() {
294            return;
295        }
296        let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, git_dir); });
297        settle_all(work_dir.as_path(), &held, &refused, FAILED);
298        let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
299        let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
300        let mut groups: Vec<((String, String), Vec<Claimed>)> = Vec::new();
301        for item in pushes {
302            let key = (item.item.repository.clone(), item.item.branch_ref.clone());
303            match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
304                Some((_, members)) => members.push(item),
305                None => groups.push((key, vec![item])),
306            }
307        }
308        for (_, claimed_members) in groups {
309            let members: Vec<Claimed> = still_held(&held, claimed_members);
310            if members.is_empty() {
311                continue;
312            }
313            let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
314            // The push carries what existed when it began; a notice written after this moment is about something newer.
315            let pushing_since: std::time::SystemTime = std::time::SystemTime::now();
316            match handlers.push(items.as_slice()) {
317                Verdict::Done => {
318                    // A push of the branch went through, so an earlier failure to push it is no longer true. The records go
319                    //  before the items settle: a service that dies between the two pushes again, and nothing is lost.
320                    if let Some(newest) = members.last() {
321                        clear_push_failures_before(git_dir, newest.item.branch_ref.as_str(), pushing_since);
322                    }
323                    settle_all(work_dir.as_path(), &held, &members, DONE);
324                }
325                Verdict::Failed(text) => {
326                    // The record goes before the items settle, so an item in `failed` always has its record.
327                    if let Some(newest) = members.last()
328                        && let Err(reason) = record_push_failure(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str())
329                    {
330                        eprintln!("cli-git-service: the failed push of {} could not be recorded for the person: {reason}", newest.item.oid);
331                    }
332                    settle_all(work_dir.as_path(), &held, &members, FAILED);
333                }
334                Verdict::Skipped(text) => {
335                    // Nothing was pushed, so an earlier failure of the branch is still true and stays. A skip with words leaves
336                    //  its own record before the items settle; one that could not be recorded settles as failed, so no item
337                    //  is done without the person having been told.
338                    let recorded: bool = match members.last() {
339                        Some(newest) if !text.is_empty() => match record_push_skip(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str()) {
340                            Ok(()) => true,
341                            Err(reason) => {
342                                eprintln!("cli-git-service: the skipped push of {} could not be recorded for the person: {reason}", newest.item.oid);
343                                false
344                            }
345                        },
346                        _ => true,
347                    };
348                    settle_all(work_dir.as_path(), &held, &members, if recorded { DONE } else { FAILED });
349                }
350            }
351        }
352        let mut upkept: Vec<String> = Vec::new();
353        for item in still_held(&held, upkeep) {
354            if !upkept.contains(&item.item.repository) {
355                upkept.push(item.item.repository.clone());
356                if let Verdict::Failed(reason) = handlers.maintenance(&item.item) {
357                    eprintln!("cli-git-service: automatic maintenance did not run: {reason}");
358                }
359            }
360            settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
361        }
362        // Reviews go last: a push is the person's backup, a review is a shadow record, and each is bounded by its budget.
363        for item in still_held(&held, reviews) {
364            match handlers.review(&item.item) {
365                Verdict::Done => settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE),
366                Verdict::Failed(reason) => {
367                    eprintln!("cli-git-service: the review of {} failed: {reason}", item.item.oid);
368                    settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), FAILED);
369                }
370                Verdict::Skipped(reason) => {
371                    eprintln!("cli-git-service: the review of {} was skipped: {reason}", item.item.oid);
372                    settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
373                }
374            }
375        }
376    }
377}
378
379/// What:
380///  Runs one round of wakes: keeps each wake's review credential in memory, drains each named repository once, and
381///  forgets the credentials when the repository is done. Gives, for each repository that has a claim another instance holds,
382///  how long until that claim's lease runs out.
383/// Why:
384///   The credential is held only for the reviews of the round that brought it; a service that restarts before the round
385///  runs has none, and its reviews record the plain "no key" outcome. The service looks at such a repository again when the
386///  time is over, because the holder may have died and nothing else would wake the work.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// for (const [dir, key] of wakes) { remember(dir, key); await drain(dir); forget(dir) }
391/// ```
392pub fn run_round(wakes: Vec<Wake>, handlers: &GitHandlers, now: &ClaimTime) -> Vec<(PathBuf, Duration)> {
393    let mut directories: Vec<PathBuf> = Vec::new();
394    for (directory, review_key) in wakes {
395        if let Some(key) = review_key {
396            handlers.remember_review_key(directory.as_path(), key);
397        }
398        if !directories.contains(&directory) {
399            directories.push(directory);
400        }
401    }
402    let mut retries: Vec<(PathBuf, Duration)> = Vec::new();
403    for directory in directories {
404        let waits: [Option<Duration>; 2] = [drain(directory.as_path(), handlers, now), drain_common_records(directory.as_path(), handlers, now)];
405        if let Some(wait) = waits.into_iter().flatten().min() {
406            retries.push((directory.clone(), wait));
407        }
408        handlers.forget_review_key(directory.as_path());
409    }
410    return retries;
411}
412
413/// The handlers that run real Git.
414pub struct GitHandlers {
415    /// The context every Git child of the service runs in.
416    pub context: GitContext,
417    /// The review credential of each repository whose wake message carried one. Held in memory only, removed when the
418    /// round that used it ends.
419    review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
420    /// Where tests point the review's gateway call, so no test reaches the real endpoint.
421    #[cfg(test)]
422    review_endpoint: Option<String>,
423}
424
425/// Methods over the real handlers.
426impl GitHandlers {
427    /// Handlers that run real Git in `context`, with no review credential yet.
428    pub fn new(context: GitContext) -> GitHandlers {
429        return GitHandlers {
430            context,
431            review_keys: std::sync::Mutex::new(std::collections::HashMap::new()),
432            #[cfg(test)]
433            review_endpoint: None,
434        };
435    }
436
437    /// The same handlers with the review's gateway call sent to `endpoint`; tests only.
438    #[cfg(test)]
439    pub fn with_review_endpoint(mut self, endpoint: &str) -> GitHandlers {
440        self.review_endpoint = Some(endpoint.to_string());
441        return self;
442    }
443
444    /// Keep the credential for the reviews of this repository until `forget_review_key`.
445    pub fn remember_review_key(&self, git_dir: &Path, key: ReviewKey) {
446        self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(git_dir.to_path_buf(), key);
447    }
448
449    /// Drop the credential of this repository.
450    pub fn forget_review_key(&self, git_dir: &Path) {
451        self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(git_dir);
452    }
453
454    fn review_key_of(&self, git_dir: &Path) -> Option<ReviewKey> {
455        return self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(git_dir).cloned();
456    }
457
458    fn push_git(&self, item: &WorkItem) -> PushGit {
459        let cwd: PathBuf = PathBuf::from(item.repository.as_str());
460        return PushGit {
461            real_git: self.context.real_git.clone(),
462            lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
463            cwd,
464            environment: std::env::vars_os().collect(),
465        };
466    }
467}
468
469/// Trait implementation for the real Git.
470impl WorkHandlers for GitHandlers {
471    fn push(&self, items: &[WorkItem]) -> Verdict {
472        let Some(newest) = items.last() else { return Verdict::Done };
473        let git: PushGit = self.push_git(newest);
474        let mut output: Vec<u8> = Vec::new();
475        return match push_once(&git, newest.branch_ref.as_str(), newest.oid.as_str(), &mut output) {
476            // A skip sent nothing: with words they are told; with none (no upstream and no origin) the repository has nowhere
477            //  to back up to and there is nothing to say, but an earlier failure of the branch is still true.
478            Ok(result) if result.outcome == AutoPushOutcome::Skipped => Verdict::Skipped(result.shown),
479            Ok(result) if result.exit_code == 0 => Verdict::Done,
480            Ok(result) => Verdict::Failed(result.shown),
481            Err(error) => Verdict::Failed(format!("{error}")),
482        };
483    }
484
485    fn maintenance(&self, item: &WorkItem) -> Verdict {
486        return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
487            Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
488            Ok(_) => Verdict::Done,
489            Err(error) => Verdict::Failed(format!("{error:?}")),
490        };
491    }
492
493    fn review(&self, item: &WorkItem) -> Verdict {
494        let key: Option<ReviewKey> = self.review_key_of(Path::new(item.git_dir.as_str()));
495        #[cfg_attr(not(test), expect(unused_mut, reason = "only a test replaces the endpoint"))]
496        let mut options: ReviewOptions = options_for(item, &review_variables(key.as_ref()));
497        #[cfg(test)]
498        if let Some(endpoint) = self.review_endpoint.as_ref() {
499            options.endpoint = endpoint.clone();
500        }
501        return review_item(&self.context, item, options, BUDGET);
502    }
503}
504
505/// Work consumer controls stay out of the release executable.
506#[cfg(test)]
507#[path = "service_work_tests.rs"]
508mod tests;