use std::collections::BTreeMap;
use onetaskgraph_plugin_api::{
Cursor, LabelFilter, Page, PageRequest, Project, ProjectQuery, SourceError, Task, TaskQuery,
TaskSource,
};
use serde_json::{Value, json};
use crate::{
LinearSource, MAX_PAGE_SIZE, ORIGIN_BATCH, RESOLUTION_ROWS, Vocabulary, connection, graphql,
map_project, map_task, slot_phrase,
};
#[derive(Default)]
pub(crate) struct OriginsAhead {
tasks: BTreeMap<String, Vec<Task>>,
projects: BTreeMap<String, Vec<Project>>,
organization: Option<String>,
pending: Vec<String>,
}
fn origin_query(origin: &str) -> TaskQuery {
TaskQuery {
origin: Some(origin.to_owned()),
..TaskQuery::default()
}
}
const MATCHED_PAGE_CURSOR: &str = "linear-origin:";
impl LinearSource {
fn origins_ahead(&self) -> Result<std::sync::MutexGuard<'_, OriginsAhead>, SourceError> {
self.origins_ahead
.lock()
.map_err(|_| SourceError::Unavailable {
message: "this source's record of the items copied from each origin was left \
inconsistent by an earlier failure; next: run the command again"
.into(),
})
}
pub(crate) fn forget_origins_ahead(&self, all: bool) {
if let Ok(mut held) = self.origins_ahead.lock() {
held.tasks.clear();
held.projects.clear();
held.pending.clear();
if all {
held.organization = None;
}
}
}
pub(crate) fn held_organization(&self) -> Result<Option<String>, SourceError> {
Ok(self.origins_ahead()?.organization.clone())
}
pub(crate) fn held_origin_tasks(
&self,
query: &TaskQuery,
page: &PageRequest,
) -> Result<Option<Page<Task>>, SourceError> {
let Some(origin) = &query.origin else {
return Ok(None);
};
if page.cursor.is_some() || *query != origin_query(origin) {
return Ok(None);
}
let Some(tasks) = self.origins_ahead()?.tasks.remove(origin) else {
return Ok(None);
};
Ok(fits(tasks.len(), page).then_some(Page {
items: tasks,
next: None,
}))
}
pub(crate) async fn read_origins_ahead(&self, origins: &[String]) -> Result<(), SourceError> {
if self.team.is_none() {
return Ok(());
}
let mut held = self.origins_ahead()?;
for origin in origins {
if slot_phrase(origin).is_some()
&& !held.tasks.contains_key(origin)
&& !held.projects.contains_key(origin)
&& !held.pending.contains(origin)
{
held.pending.push(origin.clone());
}
}
Ok(())
}
pub(crate) async fn send_origins_ahead(&self) -> Result<(), SourceError> {
let pending = std::mem::take(&mut self.origins_ahead()?.pending);
let pending: Vec<&String> = pending.iter().collect();
for chunk in pending.chunks(ORIGIN_BATCH) {
self.read_origin_chunk(chunk).await?;
}
Ok(())
}
async fn read_origin_chunk(&self, chunk: &[&String]) -> Result<(), SourceError> {
let Some(team) = self.team.as_ref() else {
return Ok(());
};
let mut issues = self.issue_scope();
issues.push(json!({"or": chunk
.iter()
.filter_map(|origin| slot_phrase(origin))
.collect::<Vec<Value>>()}));
let issues_first = u32::try_from(chunk.len())
.unwrap_or(MAX_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let projects_first = self
.projects_page()
.saturating_sub(issues_first + RESOLUTION_ROWS)
.max(1);
let data = self
.send(
self.read_document(graphql::COUNTERPART_LOOKUPS),
json!({
"issuesFirst": issues_first,
"issuesFilter": Self::narrowed(issues),
"projectsFirst": projects_first,
"projectsFilter": self.project_filter(&LabelFilter::default(), &[]),
"key": team.0,
}),
)
.await?;
if let Ok(read) = Vocabulary::read(&data, &self.name, &team.0) {
*self
.vocabulary
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(std::sync::Arc::new(read));
}
let tasks = connection(&data, "originIssues", |v| {
map_task(v, &self.name, &self.statuses, self.spelling())
})?;
let projects = connection(&data, "originProjects", |v| {
map_project(v, &self.statuses, self.spelling())
})?;
for (name, rows, first) in [
("issues", tasks.items.len(), issues_first),
("projects", projects.items.len(), projects_first),
] {
if rows as u64 > u64::from(first) {
return Err(SourceError::Malformed {
message: format!(
"the source returned {rows} {name} rows for a page of at most {first}; a \
source may return fewer than it was asked for and never more"
),
});
}
}
let organization = data
.get("organization")
.and_then(|organization| organization.get("id"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let mut held = self.origins_ahead()?;
for origin in chunk {
let query = origin_query(origin);
if tasks.next.is_none() {
let found = tasks
.items
.iter()
.filter(|task| Self::confirms(&query, task))
.cloned()
.collect();
held.tasks.insert((*origin).clone(), found);
}
if projects.next.is_none() {
let found = projects
.items
.iter()
.filter(|project| query.origin_matches(&project.metadata))
.cloned()
.collect();
held.projects.insert((*origin).clone(), found);
}
}
if !organization.trim().is_empty() {
held.organization = Some(organization);
}
Ok(())
}
pub(crate) async fn projects_by_origin(
&self,
origin: &str,
page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
self.send_origins_ahead().await?;
if page.cursor.is_none() {
let held = self.origins_ahead()?.projects.remove(origin);
if let Some(projects) = held
&& fits(projects.len(), page)
{
return Ok(Page {
items: projects,
next: None,
});
}
}
let wanted = origin_query(origin);
let mut matched = Vec::new();
let mut cursor: Option<Cursor> = None;
let mut visited: Vec<String> = Vec::new();
loop {
let listed = self
.query_projects(
&ProjectQuery::default(),
&PageRequest {
cursor: cursor.clone(),
limit: page.limit,
},
)
.await?;
if listed.items.len() as u64 > u64::from(page.limit) {
return Err(SourceError::Malformed {
message: format!(
"the source returned {} rows for a page of at most {}; a source may \
return fewer than it was asked for and never more",
listed.items.len(),
page.limit
),
});
}
matched.extend(
listed
.items
.into_iter()
.filter(|project| wanted.origin_matches(&project.metadata)),
);
cursor = match listed.next {
None => break,
Some(next) if visited.contains(&next.0) => {
return Err(SourceError::Malformed {
message: "the source returned a cursor it had already handed out while \
its projects were walked for one origin, so the walk would \
never end"
.into(),
});
}
Some(next) => {
visited.push(next.0.clone());
Some(next)
}
};
}
page_of_matches(matched, page)
}
}
fn fits(rows: usize, page: &PageRequest) -> bool {
rows as u64 <= u64::from(page.limit)
}
fn page_of_matches<T>(matched: Vec<T>, page: &PageRequest) -> Result<Page<T>, SourceError> {
let start = match &page.cursor {
None => 0,
Some(cursor) => cursor
.0
.strip_prefix(MATCHED_PAGE_CURSOR)
.and_then(|position| position.parse::<usize>().ok())
.filter(|position| *position <= matched.len())
.ok_or_else(|| SourceError::Refused {
message: format!(
"cursor {:?} was not handed out by this by-origin listing; start the walk \
again without one",
cursor.0
),
})?,
};
let end = start.saturating_add(page.limit as usize).min(matched.len());
let next = (end < matched.len()).then(|| Cursor(format!("{MATCHED_PAGE_CURSOR}{end}")));
Ok(Page {
items: matched.into_iter().skip(start).take(end - start).collect(),
next,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_by_origin_page_walks_its_matches_by_the_cursor_it_hands_out() {
let page = |cursor: Option<&str>| PageRequest {
cursor: cursor.map(|c| Cursor(c.into())),
limit: 2,
};
let first = page_of_matches(vec![1, 2, 3], &page(None)).unwrap();
assert_eq!(first.items, vec![1, 2]);
let next = first.next.expect("a next page");
let second = page_of_matches(vec![1, 2, 3], &page(Some(&next.0))).unwrap();
assert_eq!(second.items, vec![3]);
assert!(second.next.is_none());
assert!(page_of_matches(vec![1], &page(Some("elsewhere"))).is_err());
}
#[test]
fn only_a_copys_own_origin_query_is_answered_from_what_was_held() {
let mut query = origin_query("plan:T-1");
assert_eq!(query, origin_query("plan:T-1"));
query.project = onetaskgraph_plugin_api::ProjectFilter::Orphans;
assert_ne!(query, origin_query("plan:T-1"));
}
}