Skip to main content

rectilinear_core/linear/
pagination.rs

1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::hash::Hash;
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use thiserror::Error;
9
10const DEFAULT_COMPLEXITY_TARGET: usize = 7_000;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum LinearOperation {
14    Teams,
15    Labels,
16    Projects,
17    ProjectTeams,
18    ProjectMembers,
19    ProjectLabels,
20    ProjectMilestones,
21    Cycles,
22    Issues,
23    Comments,
24    Relations,
25}
26
27impl LinearOperation {
28    pub fn name(self) -> &'static str {
29        match self {
30            Self::Teams => "teams",
31            Self::Labels => "labels",
32            Self::Projects => "projects",
33            Self::ProjectTeams => "project teams",
34            Self::ProjectMembers => "project members",
35            Self::ProjectLabels => "project labels",
36            Self::ProjectMilestones => "project milestones",
37            Self::Cycles => "cycles",
38            Self::Issues => "issues",
39            Self::Comments => "comments",
40            Self::Relations => "issue relations",
41        }
42    }
43
44    fn environment_name(self) -> &'static str {
45        match self {
46            Self::Teams => "TEAMS",
47            Self::Labels => "LABELS",
48            Self::Projects => "PROJECTS",
49            Self::ProjectTeams => "PROJECT_TEAMS",
50            Self::ProjectMembers => "PROJECT_MEMBERS",
51            Self::ProjectLabels => "PROJECT_LABELS",
52            Self::ProjectMilestones => "PROJECT_MILESTONES",
53            Self::Cycles => "CYCLES",
54            Self::Issues => "ISSUES",
55            Self::Comments => "COMMENTS",
56            Self::Relations => "RELATIONS",
57        }
58    }
59
60    fn recommended_page_size(self) -> usize {
61        match self {
62            Self::Projects => 25,
63            Self::Issues => 50,
64            Self::ProjectMembers | Self::ProjectLabels => 50,
65            Self::ProjectTeams => 25,
66            Self::Comments | Self::Relations => 100,
67            Self::Teams | Self::Labels | Self::ProjectMilestones | Self::Cycles => 100,
68        }
69    }
70
71    fn estimated_complexity(self) -> (usize, usize) {
72        // (fixed request cost, conservative per-node cost). These are planning
73        // weights, not Linear's private scoring formula. They deliberately
74        // overestimate shallow scalar selections and nested reference fields.
75        match self {
76            Self::Projects => (100, 220),
77            Self::Issues => (100, 115),
78            Self::ProjectMembers | Self::ProjectLabels => (50, 80),
79            Self::ProjectTeams => (50, 120),
80            Self::Comments => (50, 45),
81            Self::Relations => (50, 55),
82            Self::ProjectMilestones => (50, 60),
83            Self::Cycles => (50, 45),
84            Self::Labels => (50, 35),
85            Self::Teams => (50, 30),
86        }
87    }
88
89    fn field_set(self) -> &'static str {
90        match self {
91            Self::Teams => "team identity fields",
92            Self::Labels => "label identity, color, and parent",
93            Self::Projects => "project scalar metadata, status, and lead",
94            Self::ProjectTeams => "project team identity fields",
95            Self::ProjectMembers => "project member identity fields",
96            Self::ProjectLabels => "project label metadata",
97            Self::ProjectMilestones => "milestone scalar metadata and project reference",
98            Self::Cycles => "cycle scalar metadata and team reference",
99            Self::Issues => {
100                "issue scalar metadata, labels, project, milestone, and cycle references"
101            }
102            Self::Comments => "comment body, author, timestamps, parent, and URL",
103            Self::Relations => "relation type and related issue identity",
104        }
105    }
106}
107
108#[derive(Debug, Clone)]
109pub struct SyncQueryConfig {
110    pub complexity_target: usize,
111    pub minimum_page_size: usize,
112    pub max_retry_attempts: usize,
113    pub retry_base_delay: Duration,
114    pub verbose: bool,
115    page_size_overrides: HashMap<LinearOperation, usize>,
116}
117
118impl Default for SyncQueryConfig {
119    fn default() -> Self {
120        Self {
121            complexity_target: DEFAULT_COMPLEXITY_TARGET,
122            minimum_page_size: 1,
123            max_retry_attempts: 3,
124            retry_base_delay: Duration::from_millis(250),
125            verbose: false,
126            page_size_overrides: HashMap::new(),
127        }
128    }
129}
130
131impl SyncQueryConfig {
132    pub fn from_environment() -> Self {
133        let mut config = Self::default();
134        if let Some(value) = env_usize("RECTILINEAR_LINEAR_COMPLEXITY_TARGET") {
135            config.complexity_target = value.clamp(100, 9_000);
136        }
137        if let Some(value) = env_usize("RECTILINEAR_LINEAR_MIN_PAGE_SIZE") {
138            config.minimum_page_size = value.max(1);
139        }
140        config.verbose = std::env::var("RECTILINEAR_LINEAR_VERBOSE")
141            .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
142        for operation in [
143            LinearOperation::Teams,
144            LinearOperation::Labels,
145            LinearOperation::Projects,
146            LinearOperation::ProjectTeams,
147            LinearOperation::ProjectMembers,
148            LinearOperation::ProjectLabels,
149            LinearOperation::ProjectMilestones,
150            LinearOperation::Cycles,
151            LinearOperation::Issues,
152            LinearOperation::Comments,
153            LinearOperation::Relations,
154        ] {
155            let key = format!(
156                "RECTILINEAR_LINEAR_{}_PAGE_SIZE",
157                operation.environment_name()
158            );
159            if let Some(value) = env_usize(&key) {
160                config.page_size_overrides.insert(operation, value.max(1));
161            }
162        }
163        config
164    }
165
166    pub fn with_page_size(mut self, operation: LinearOperation, page_size: usize) -> Self {
167        self.page_size_overrides.insert(operation, page_size.max(1));
168        self
169    }
170
171    pub fn page_size(&self, operation: LinearOperation) -> usize {
172        if let Some(page_size) = self.page_size_overrides.get(&operation) {
173            return (*page_size).max(self.minimum_page_size);
174        }
175        let (base_cost, per_node_cost) = operation.estimated_complexity();
176        let planned = self
177            .complexity_target
178            .saturating_sub(base_cost)
179            .checked_div(per_node_cost)
180            .unwrap_or(1)
181            .max(1);
182        operation
183            .recommended_page_size()
184            .min(planned)
185            .max(self.minimum_page_size)
186    }
187
188    pub fn estimated_request_complexity(
189        &self,
190        operation: LinearOperation,
191        page_size: usize,
192    ) -> usize {
193        let (base_cost, per_node_cost) = operation.estimated_complexity();
194        base_cost.saturating_add(per_node_cost.saturating_mul(page_size))
195    }
196}
197
198fn env_usize(key: &str) -> Option<usize> {
199    std::env::var(key).ok()?.parse().ok()
200}
201
202#[derive(Debug, Clone, Deserialize)]
203pub(crate) struct PageInfo {
204    #[serde(rename = "hasNextPage")]
205    pub(crate) has_next_page: bool,
206    #[serde(rename = "endCursor")]
207    pub(crate) end_cursor: Option<String>,
208}
209
210#[derive(Debug)]
211pub(crate) struct ConnectionPage<T> {
212    pub(crate) nodes: Vec<T>,
213    pub(crate) page_info: PageInfo,
214}
215
216#[derive(Debug, Clone)]
217pub(crate) struct PageRequest {
218    pub(crate) cursor: Option<String>,
219    pub(crate) page_size: usize,
220    pub(crate) page_number: usize,
221}
222
223#[derive(Debug, Clone)]
224pub(crate) struct PageContext {
225    pub(crate) page_size: usize,
226    pub(crate) cursor: Option<String>,
227}
228
229#[derive(Debug, Clone)]
230pub struct SyncEvent {
231    pub operation: &'static str,
232    pub parent: Option<String>,
233    pub page_number: usize,
234    pub nodes_received: usize,
235    pub page_size: usize,
236    pub adaptive_reduction: bool,
237    pub completed: bool,
238    pub failure: Option<String>,
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub(crate) struct PaginationStats {
243    pub(crate) pages: usize,
244    pub(crate) nodes: usize,
245    pub(crate) adaptive_reductions: usize,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum LinearErrorKind {
250    Authentication,
251    RateLimit,
252    Complexity,
253    Validation,
254    Transport,
255    Transient,
256    Api,
257}
258
259#[derive(Debug, Error)]
260#[error("Linear {kind:?} error during {operation}{cursor_context}: {message}")]
261pub struct LinearOperationError {
262    pub kind: LinearErrorKind,
263    pub operation: String,
264    pub cursor: Option<String>,
265    pub message: String,
266    pub retry_after: Option<Duration>,
267    cursor_context: String,
268}
269
270impl LinearOperationError {
271    pub fn new(
272        kind: LinearErrorKind,
273        operation: impl Into<String>,
274        cursor: Option<&str>,
275        message: impl Into<String>,
276    ) -> Self {
277        let cursor = cursor.map(ToString::to_string);
278        let cursor_context = cursor
279            .as_deref()
280            .map(|value| format!(" at cursor '{value}'"))
281            .unwrap_or_default();
282        Self {
283            kind,
284            operation: operation.into(),
285            cursor,
286            message: message.into(),
287            retry_after: None,
288            cursor_context,
289        }
290    }
291
292    pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
293        self.retry_after = retry_after;
294        self
295    }
296}
297
298pub(crate) fn operation_error(error: &anyhow::Error) -> Option<&LinearOperationError> {
299    error.downcast_ref::<LinearOperationError>()
300}
301
302pub(crate) async fn paginate<T, K, Fetch, FetchFuture, Persist, PersistFuture, KeyFn, Observe>(
303    config: &SyncQueryConfig,
304    operation: LinearOperation,
305    parent: Option<String>,
306    mut fetch: Fetch,
307    mut persist: Persist,
308    key_of: KeyFn,
309    mut observe: Observe,
310) -> Result<PaginationStats>
311where
312    K: Eq + Hash,
313    Fetch: FnMut(PageRequest) -> FetchFuture,
314    FetchFuture: Future<Output = Result<ConnectionPage<T>>>,
315    Persist: FnMut(Vec<T>, PageContext) -> PersistFuture,
316    PersistFuture: Future<Output = Result<()>>,
317    KeyFn: Fn(&T) -> K,
318    Observe: FnMut(SyncEvent),
319{
320    let mut cursor = None;
321    let mut page_size = config.page_size(operation);
322    let mut stats = PaginationStats::default();
323    let mut retry_attempts = 0;
324    let mut seen = HashSet::new();
325
326    loop {
327        let request = PageRequest {
328            cursor: cursor.clone(),
329            page_size,
330            page_number: stats.pages + 1,
331        };
332        let page = match fetch(request.clone()).await {
333            Ok(page) => {
334                retry_attempts = 0;
335                page
336            }
337            Err(error) => {
338                let classified = operation_error(&error);
339                if classified.is_some_and(|value| value.kind == LinearErrorKind::Complexity) {
340                    if page_size > config.minimum_page_size {
341                        page_size = (page_size / 2).max(config.minimum_page_size);
342                        stats.adaptive_reductions += 1;
343                        observe(SyncEvent {
344                            operation: operation.name(),
345                            parent: parent.clone(),
346                            page_number: request.page_number,
347                            nodes_received: 0,
348                            page_size,
349                            adaptive_reduction: true,
350                            completed: false,
351                            failure: None,
352                        });
353                        continue;
354                    }
355                    let diagnostic = if page_size == 1 {
356                        format!(
357                            "Linear rejected a one-node {} request as too complex at cursor {:?}; \
358                             split the operation or reduce the requested field set ({})",
359                            operation.name(),
360                            cursor,
361                            operation.field_set()
362                        )
363                    } else {
364                        format!(
365                            "Linear rejected the minimum configured page size ({page_size}) for {} \
366                             at cursor {:?}; lower RECTILINEAR_LINEAR_MIN_PAGE_SIZE or split the \
367                             requested field set ({})",
368                            operation.name(),
369                            cursor,
370                            operation.field_set()
371                        )
372                    };
373                    observe(SyncEvent {
374                        operation: operation.name(),
375                        parent: parent.clone(),
376                        page_number: request.page_number,
377                        nodes_received: 0,
378                        page_size,
379                        adaptive_reduction: stats.adaptive_reductions > 0,
380                        completed: false,
381                        failure: Some(diagnostic.clone()),
382                    });
383                    anyhow::bail!(diagnostic);
384                }
385
386                let retry_delay = classified.and_then(|value| match value.kind {
387                    LinearErrorKind::RateLimit
388                    | LinearErrorKind::Transport
389                    | LinearErrorKind::Transient
390                        if retry_attempts < config.max_retry_attempts =>
391                    {
392                        Some(value.retry_after.unwrap_or_else(|| {
393                            config
394                                .retry_base_delay
395                                .saturating_mul(1_u32 << retry_attempts.min(10))
396                        }))
397                    }
398                    _ => None,
399                });
400                if let Some(delay) = retry_delay {
401                    retry_attempts += 1;
402                    observe(SyncEvent {
403                        operation: operation.name(),
404                        parent: parent.clone(),
405                        page_number: request.page_number,
406                        nodes_received: 0,
407                        page_size,
408                        adaptive_reduction: stats.adaptive_reductions > 0,
409                        completed: false,
410                        failure: Some(format!(
411                            "retrying attempt {retry_attempts}/{} after {delay:?} following a {:?} error",
412                            config.max_retry_attempts,
413                            classified.expect("retryable errors are classified").kind
414                        )),
415                    });
416                    if !delay.is_zero() {
417                        tokio::time::sleep(delay).await;
418                    }
419                    continue;
420                }
421
422                let failure = format!("{error:#}");
423                observe(SyncEvent {
424                    operation: operation.name(),
425                    parent: parent.clone(),
426                    page_number: request.page_number,
427                    nodes_received: 0,
428                    page_size,
429                    adaptive_reduction: stats.adaptive_reductions > 0,
430                    completed: false,
431                    failure: Some(failure),
432                });
433                return Err(error).with_context(|| {
434                    format!(
435                        "failed to paginate {} at cursor {:?}",
436                        operation.name(),
437                        cursor
438                    )
439                });
440            }
441        };
442
443        if page.page_info.has_next_page && page.page_info.end_cursor.is_none() {
444            anyhow::bail!(
445                "Malformed {} pagination response on page {}: hasNextPage was true but endCursor was missing",
446                operation.name(),
447                request.page_number
448            );
449        }
450        if page.page_info.has_next_page && page.page_info.end_cursor == cursor {
451            anyhow::bail!(
452                "Malformed {} pagination response on page {}: endCursor did not advance",
453                operation.name(),
454                request.page_number
455            );
456        }
457
458        let mut nodes = page.nodes;
459        nodes.retain(|node| seen.insert(key_of(node)));
460        let received = nodes.len();
461        persist(
462            nodes,
463            PageContext {
464                page_size,
465                cursor: cursor.clone(),
466            },
467        )
468        .await
469        .with_context(|| {
470            format!(
471                "failed to persist {} page {} at cursor {:?}",
472                operation.name(),
473                request.page_number,
474                cursor
475            )
476        })?;
477        stats.pages += 1;
478        stats.nodes += received;
479        let completed = !page.page_info.has_next_page;
480        observe(SyncEvent {
481            operation: operation.name(),
482            parent: parent.clone(),
483            page_number: request.page_number,
484            nodes_received: received,
485            page_size,
486            adaptive_reduction: stats.adaptive_reductions > 0,
487            completed,
488            failure: None,
489        });
490        if completed {
491            return Ok(stats);
492        }
493        cursor = page.page_info.end_cursor;
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use std::collections::VecDeque;
500    use std::future::ready;
501
502    use super::*;
503
504    fn page(
505        nodes: &[usize],
506        has_next_page: bool,
507        end_cursor: Option<&str>,
508    ) -> ConnectionPage<usize> {
509        ConnectionPage {
510            nodes: nodes.to_vec(),
511            page_info: PageInfo {
512                has_next_page,
513                end_cursor: end_cursor.map(ToString::to_string),
514            },
515        }
516    }
517
518    fn run<F>(future: F) -> F::Output
519    where
520        F: Future,
521    {
522        tokio::runtime::Builder::new_current_thread()
523            .enable_time()
524            .build()
525            .unwrap()
526            .block_on(future)
527    }
528
529    #[test]
530    fn single_page_connection_yields_each_node() {
531        let mut persisted = Vec::new();
532        let stats = run(paginate(
533            &SyncQueryConfig::default(),
534            LinearOperation::Issues,
535            None,
536            |_| ready(Ok(page(&[1, 2], false, None))),
537            |nodes, _| {
538                persisted.extend(nodes);
539                ready(Ok(()))
540            },
541            |node| *node,
542            |_| {},
543        ))
544        .unwrap();
545        assert_eq!(persisted, [1, 2]);
546        assert_eq!(stats.pages, 1);
547    }
548
549    #[test]
550    fn multiple_pages_traverse_cursors_and_remove_boundary_duplicates() {
551        let mut responses = VecDeque::from([
552            page(&[1, 2], true, Some("cursor-1")),
553            page(&[2, 3], false, None),
554        ]);
555        let mut requested = Vec::new();
556        let mut persisted = Vec::new();
557        let stats = run(paginate(
558            &SyncQueryConfig::default(),
559            LinearOperation::Issues,
560            None,
561            |request| {
562                requested.push(request.cursor);
563                ready(Ok(responses.pop_front().unwrap()))
564            },
565            |nodes, _| {
566                persisted.extend(nodes);
567                ready(Ok(()))
568            },
569            |node| *node,
570            |_| {},
571        ))
572        .unwrap();
573        assert_eq!(requested, [None, Some("cursor-1".into())]);
574        assert_eq!(persisted, [1, 2, 3]);
575        assert_eq!(stats.nodes, 3);
576    }
577
578    #[test]
579    fn empty_connection_persists_an_empty_page() {
580        let mut pages = 0;
581        let stats = run(paginate(
582            &SyncQueryConfig::default(),
583            LinearOperation::Comments,
584            None,
585            |_| ready(Ok(page(&[], false, None))),
586            |nodes, _| {
587                assert!(nodes.is_empty());
588                pages += 1;
589                ready(Ok(()))
590            },
591            |node| *node,
592            |_| {},
593        ))
594        .unwrap();
595        assert_eq!(pages, 1);
596        assert_eq!(stats.nodes, 0);
597    }
598
599    #[test]
600    fn malformed_pagination_metadata_is_rejected() {
601        let error = run(paginate(
602            &SyncQueryConfig::default(),
603            LinearOperation::Comments,
604            None,
605            |_| ready(Ok(page(&[1], true, None))),
606            |_, _| ready(Ok(())),
607            |node| *node,
608            |_| {},
609        ))
610        .unwrap_err();
611        assert!(error.to_string().contains("endCursor was missing"));
612    }
613
614    #[test]
615    fn transient_transport_failure_retries_same_cursor() {
616        let mut attempts = 0;
617        let mut events = Vec::new();
618        let config = SyncQueryConfig {
619            retry_base_delay: Duration::ZERO,
620            ..Default::default()
621        };
622        let stats = run(paginate(
623            &config,
624            LinearOperation::Issues,
625            None,
626            |request| {
627                attempts += 1;
628                if attempts == 1 {
629                    ready(Err(LinearOperationError::new(
630                        LinearErrorKind::Transport,
631                        "issues",
632                        request.cursor.as_deref(),
633                        "connection reset",
634                    )
635                    .into()))
636                } else {
637                    ready(Ok(page(&[1], false, None)))
638                }
639            },
640            |_, _| ready(Ok(())),
641            |node| *node,
642            |event| events.push(event),
643        ))
644        .unwrap();
645        assert_eq!(attempts, 2);
646        assert_eq!(stats.nodes, 1);
647        assert!(events.iter().any(|event| {
648            event
649                .failure
650                .as_deref()
651                .is_some_and(|failure| failure.starts_with("retrying attempt 1/"))
652        }));
653    }
654
655    #[test]
656    fn rate_limit_retries_without_advancing_cursor() {
657        let mut cursors = Vec::new();
658        let config = SyncQueryConfig {
659            retry_base_delay: Duration::ZERO,
660            ..Default::default()
661        };
662        run(paginate(
663            &config,
664            LinearOperation::Comments,
665            None,
666            |request| {
667                cursors.push(request.cursor.clone());
668                if cursors.len() == 1 {
669                    ready(Err(LinearOperationError::new(
670                        LinearErrorKind::RateLimit,
671                        "comments",
672                        request.cursor.as_deref(),
673                        "too many requests",
674                    )
675                    .into()))
676                } else {
677                    ready(Ok(page(&[], false, None)))
678                }
679            },
680            |_, _| ready(Ok(())),
681            |node| *node,
682            |_| {},
683        ))
684        .unwrap();
685        assert_eq!(cursors, [None, None]);
686    }
687
688    #[test]
689    fn complexity_rejection_reduces_page_size_at_same_cursor() {
690        let config = SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 40);
691        let mut sizes = Vec::new();
692        let stats = run(paginate(
693            &config,
694            LinearOperation::Issues,
695            None,
696            |request| {
697                sizes.push(request.page_size);
698                if request.page_size > 10 {
699                    ready(Err(LinearOperationError::new(
700                        LinearErrorKind::Complexity,
701                        "issues",
702                        request.cursor.as_deref(),
703                        "Query complexity exceeds maximum allowed complexity",
704                    )
705                    .into()))
706                } else {
707                    ready(Ok(page(&[1], false, None)))
708                }
709            },
710            |_, _| ready(Ok(())),
711            |node| *node,
712            |_| {},
713        ))
714        .unwrap();
715        assert_eq!(sizes, [40, 20, 10]);
716        assert_eq!(stats.adaptive_reductions, 2);
717    }
718
719    #[test]
720    fn repeated_complexity_rejection_at_minimum_is_actionable() {
721        let config = SyncQueryConfig::default().with_page_size(LinearOperation::Projects, 4);
722        let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
723            &config,
724            LinearOperation::Projects,
725            None,
726            |request| {
727                ready(Err(LinearOperationError::new(
728                    LinearErrorKind::Complexity,
729                    "projects",
730                    request.cursor.as_deref(),
731                    "too complex",
732                )
733                .into()))
734            },
735            |_, _| ready(Ok(())),
736            |node| *node,
737            |_| {},
738        ))
739        .unwrap_err();
740        assert!(error.to_string().contains("one-node projects request"));
741        assert!(error.to_string().contains("field set"));
742    }
743
744    #[test]
745    fn repeated_complexity_rejection_stops_at_configured_minimum() {
746        let mut sizes = Vec::new();
747        let config = SyncQueryConfig {
748            minimum_page_size: 5,
749            ..SyncQueryConfig::default().with_page_size(LinearOperation::Issues, 20)
750        };
751        let error = run(paginate::<usize, usize, _, _, _, _, _, _>(
752            &config,
753            LinearOperation::Issues,
754            None,
755            |request| {
756                sizes.push(request.page_size);
757                ready(Err(LinearOperationError::new(
758                    LinearErrorKind::Complexity,
759                    "issues",
760                    request.cursor.as_deref(),
761                    "too complex",
762                )
763                .into()))
764            },
765            |_, _| ready(Ok(())),
766            |node| *node,
767            |_| {},
768        ))
769        .unwrap_err();
770        assert_eq!(sizes, [20, 10, 5]);
771        assert!(error
772            .to_string()
773            .contains("minimum configured page size (5)"));
774    }
775
776    #[test]
777    fn simulated_large_workspace_stays_under_target() {
778        let config = SyncQueryConfig::default();
779        let old_workspace_query_complexity = 72_400;
780        assert!(old_workspace_query_complexity > 10_000);
781        let issue_count: usize = 1_200;
782        let issue_page_size = config.page_size(LinearOperation::Issues);
783        let issue_request_count = issue_count.div_ceil(issue_page_size);
784        assert_eq!(issue_page_size, 50);
785        assert_eq!(issue_request_count, 24);
786        for operation in [
787            LinearOperation::Teams,
788            LinearOperation::Labels,
789            LinearOperation::Projects,
790            LinearOperation::ProjectTeams,
791            LinearOperation::ProjectMembers,
792            LinearOperation::ProjectLabels,
793            LinearOperation::ProjectMilestones,
794            LinearOperation::Cycles,
795            LinearOperation::Issues,
796            LinearOperation::Comments,
797            LinearOperation::Relations,
798        ] {
799            let size = config.page_size(operation);
800            assert!(
801                config.estimated_request_complexity(operation, size) <= config.complexity_target,
802                "{} planned above target",
803                operation.name()
804            );
805        }
806    }
807}