use std::future::Future;
use std::time::{Duration, Instant};
use onetaskgraph_plugin_api::{DocumentQuery, NativeId, PageRequest, TaskQuery, TaskSource};
use serde_json::{Value, json};
#[derive(Clone, Copy, Debug)]
pub struct Bound {
pub reads: u32,
pub interval: Duration,
}
pub const LINEAR_INDEX: Bound = Bound {
reads: 21,
interval: Duration::from_secs(1),
};
pub async fn settled<F, Fut>(
bound: Bound,
what: &str,
expected: &[String],
mut read: F,
) -> Result<(), String>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Vec<String>, String>>,
{
let mut expected = expected.to_vec();
expected.sort();
let started = Instant::now();
let mut listed = read().await?;
let mut reads = 1;
while listed != expected && reads < bound.reads {
tokio::time::sleep(bound.interval).await;
listed = read().await?;
reads += 1;
}
if listed == expected {
return Ok(());
}
Err(format!(
"{what} came back as {listed:?} rather than {expected:?}, still after {reads} reads \
over {:?}",
started.elapsed()
))
}
pub async fn task_titles(
source: &dyn TaskSource,
query: &TaskQuery,
limit: u32,
what: &str,
) -> Result<Vec<String>, String> {
let mut titles = source
.query_tasks(
query,
&PageRequest {
cursor: None,
limit,
},
)
.await
.map_err(|error| format!("{what} could not be read: {error}"))?
.items
.into_iter()
.map(|task| task.title)
.collect::<Vec<_>>();
titles.sort();
Ok(titles)
}
pub async fn walked_task_titles(
source: &dyn TaskSource,
query: &TaskQuery,
most: usize,
what: &str,
) -> Result<Vec<String>, String> {
let mut walked = Vec::new();
let mut cursor = None;
for _ in 0..most {
let step = source
.query_tasks(query, &PageRequest { cursor, limit: 1 })
.await
.map_err(|error| format!("{what} could not be read: {error}"))?;
if step.items.len() > 1 {
return Err(format!(
"{what} returned {} rows on a page of one",
step.items.len()
));
}
walked.extend(step.items.into_iter().map(|task| task.title));
cursor = step.next;
if cursor.is_none() {
return Ok(walked);
}
}
Err(format!(
"{what} had not ended after {most} pages, having reached {walked:?}"
))
}
#[derive(Clone, Copy, Debug)]
pub struct DocumentListingBudget {
pub pages: usize,
pub elapsed: Duration,
}
pub const LINEAR_DOCUMENT_LISTING: DocumentListingBudget = DocumentListingBudget {
pages: 100,
elapsed: Duration::from_secs(30),
};
pub async fn documents_from_run(
source: &dyn TaskSource,
query: &DocumentQuery,
run_ids: &[NativeId],
budget: DocumentListingBudget,
what: &str,
) -> Result<Vec<(NativeId, String)>, String> {
let started = Instant::now();
let mut found = Vec::new();
let mut cursor = None;
for page in 1..=budget.pages {
let request = PageRequest {
cursor,
limit: onetaskgraph_linear::MAX_PAGE_SIZE,
};
let read = source.query_documents(query, &request);
let Some(remaining) = budget.elapsed.checked_sub(started.elapsed()) else {
return Err(format!(
"{what} exhausted its document listing budget after {} pages over {:?}, before the listing ended",
page - 1,
started.elapsed()
));
};
let step = tokio::time::timeout(remaining, read)
.await
.map_err(|_| {
format!(
"{what} exhausted its document listing budget after {} pages over {:?}, before the listing ended",
page - 1,
started.elapsed()
)
})?
.map_err(|error| format!("{what} could not be read: {error}"))?;
found.extend(step.items.into_iter().filter_map(|document| {
run_ids
.contains(&document.id)
.then_some((document.id, document.title))
}));
cursor = step.next;
if cursor.is_none() {
found.sort_by(|left, right| left.0.cmp(&right.0));
return Ok(found);
}
if page == budget.pages || started.elapsed() >= budget.elapsed {
return Err(format!(
"{what} exhausted its document listing budget after {page} pages over {:?}, before the listing ended",
started.elapsed()
));
}
}
unreachable!("a positive document page budget returns inside the loop")
}
pub async fn settled_tasks(
bound: Bound,
source: &dyn TaskSource,
query: &TaskQuery,
what: &str,
expected: &[String],
) -> Result<(), String> {
settled(bound, what, expected, || {
task_titles(source, query, 50, what)
})
.await
}
pub async fn settled_walk(
bound: Bound,
source: &dyn TaskSource,
query: &TaskQuery,
most: usize,
what: &str,
expected: &[String],
) -> Result<(), String> {
settled(bound, what, expected, || async move {
let mut walked = walked_task_titles(source, query, most, what).await?;
walked.sort();
Ok(walked)
})
.await
}
pub const LABEL_CONNECTION: &str = "issueLabels";
pub const LABEL_VARIABLE: &str = "name";
pub async fn settled_label<F, Fut>(bound: Bound, name: &str, send: F) -> Result<(), String>
where
F: Fn(&'static str, Value) -> Fut,
Fut: Future<Output = Result<Value, String>>,
{
let what = format!("the label named {name:?} by the lookup a write resolves it through");
let (what, send) = (what.as_str(), &send);
let started = Instant::now();
let mut reads = 0;
loop {
let data = send(
onetaskgraph_linear::graphql::ISSUE_LABEL,
json!({ LABEL_VARIABLE: name }),
)
.await
.map_err(|error| format!("{what} could not be read: {error}"))?;
let nodes = data
.get(LABEL_CONNECTION)
.and_then(|connection| connection.get("nodes"))
.and_then(Value::as_array)
.ok_or_else(|| {
format!("{what} could not be read: no {LABEL_CONNECTION}.nodes in {data}")
})?;
reads += 1;
let ids = nodes
.iter()
.map(|node| {
node.get("id").and_then(Value::as_str).ok_or_else(|| {
format!("{what} could not be read: label node has no string id: {node}")
})
})
.collect::<Result<Vec<_>, _>>()?;
match nodes.len() {
1 => return Ok(()),
0 if reads < bound.reads => tokio::time::sleep(bound.interval).await,
0 => {
return Err(format!(
"{what} found 0 matches, still after {reads} reads over {:?}",
started.elapsed()
));
}
found => {
return Err(format!("{what} found {} matches with ids {ids:?}", found));
}
}
}
}
pub async fn settled_documents(
bound: Bound,
listing_budget: DocumentListingBudget,
source: &dyn TaskSource,
query: &DocumentQuery,
run_ids: &[NativeId],
what: &str,
expected: &[(NativeId, String)],
) -> Result<(), String> {
assert!(
listing_budget.pages > 0,
"a document listing budget needs a page"
);
let started = Instant::now();
let mut reads = 0;
loop {
let mut listed = documents_from_run(source, query, run_ids, listing_budget, what).await?;
listed.sort_by(|left, right| left.0.cmp(&right.0));
let mut expected = expected.to_vec();
expected.sort_by(|left, right| left.0.cmp(&right.0));
reads += 1;
if listed == expected {
return Ok(());
}
if reads == bound.reads {
let missing = expected
.iter()
.filter(|(id, _)| !listed.iter().any(|(listed_id, _)| listed_id == id))
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"{what} completed its listing but the document ids {missing:?} were missing, still after {reads} reads over {:?}",
started.elapsed()
));
}
return Err(format!(
"{what} completed its listing with {listed:?} rather than {expected:?}, still after {reads} reads over {:?}",
started.elapsed()
));
}
tokio::time::sleep(bound.interval).await;
}
}
pub async fn settled_document_absent(
bound: Bound,
source: &dyn TaskSource,
id: &NativeId,
name: &str,
) -> Result<(), String> {
let what = format!("the deleted document {name:?}");
settled(bound, &what, &[], || async {
source
.get_document(id)
.await
.map(|document| {
document
.into_iter()
.map(|document| document.title)
.collect()
})
.map_err(|error| format!("{what} could not be read: {error}"))
})
.await
}