use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard};
use onetaskgraph_plugin_api::{
AssetUploads, JournalEntry, NativeId, PartOutcome, Repository, RequestKind, Shown, SourceError,
WholeTask, WriteJournal,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{
BOARD_ITEMS_PAGE_SIZE, BoardFields, BoardKind, GitHubProjectsSource, ItemKind, MAX_PAGE_SIZE,
NESTED_PAGE_SIZE, RepositoryTarget, Resolved, SERVED_AT, change_token, comment_page, graphql,
is_mutation, operation_description, optional_time,
};
pub const CONTEXT_VERSION: u64 = 1;
#[must_use]
pub fn context_shapes() -> Value {
json!({
"task": schemars::schema_for!(Context),
"board": schemars::schema_for!(Board),
})
}
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
struct Context {
version: u64,
item: Resolved,
comments: Option<BTreeMap<NativeId, String>>,
}
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
struct Board {
version: u64,
fields: BoardFields,
}
#[derive(Default)]
pub(super) struct Held {
journal: Mutex<Option<WriteJournal>>,
learnt: Mutex<BTreeMap<NativeId, BTreeMap<NativeId, String>>>,
primed: Mutex<BTreeMap<NativeId, Option<BTreeMap<NativeId, String>>>>,
narrowed: Mutex<std::collections::BTreeSet<NativeId>>,
}
fn held<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Held {
pub(super) fn journaling(&self) -> bool {
held(&self.journal).is_some()
}
pub(super) fn begin(&self) {
*held(&self.journal) = Some(WriteJournal::default());
}
pub(super) fn end(&self) -> WriteJournal {
held(&self.journal).take().unwrap_or_default()
}
fn record(&self, entry: JournalEntry) {
if let Some(journal) = held(&self.journal).as_mut() {
journal.requests.push(entry);
}
}
pub(super) fn learn_comments(&self, issue: &NativeId, node: &Value) {
let Some(connection) = node.get("comments") else {
return;
};
if connection.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
return;
}
let Some(nodes) = connection.get("nodes").and_then(Value::as_array) else {
return;
};
let Some(comments) = nodes
.iter()
.map(|comment| {
Some((
NativeId(comment.get("id")?.as_str()?.to_owned()),
comment.get("body")?.as_str()?.to_owned(),
))
})
.collect::<Option<BTreeMap<_, _>>>()
else {
held(&self.learnt).remove(issue);
return;
};
held(&self.learnt).insert(issue.clone(), comments);
}
pub(super) fn is_primed(&self, id: &NativeId) -> bool {
held(&self.primed).contains_key(id) || held(&self.narrowed).contains(id)
}
pub(super) fn end_command(&self) {
held(&self.narrowed).clear();
}
pub(super) fn primed_comment(
&self,
issue: &NativeId,
comment: &NativeId,
) -> Option<Option<String>> {
held(&self.primed)
.get(issue)
.and_then(Option::as_ref)
.map(|comments| comments.get(comment).cloned())
}
}
pub(super) fn whole_variables(mut variables: Value) -> Value {
if let Some(members) = variables.as_object_mut() {
members.insert("first".to_owned(), json!(MAX_PAGE_SIZE));
members.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
members.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
members.insert("duplicates".to_owned(), json!(true));
}
variables
}
fn part_of(document: &str) -> String {
let part = if [
graphql::ADD_COMMENT,
graphql::UPDATE_COMMENT,
graphql::DELETE_COMMENT,
graphql::ADD_COMMENT_WHOLE,
graphql::UPDATE_COMMENT_WHOLE,
]
.contains(&document)
{
"comment"
} else if [graphql::UPDATE_ISSUE, graphql::UPDATE_ISSUE_WHOLE].contains(&document) {
"issue"
} else if [
graphql::UPDATE_FIELD,
graphql::UPDATE_FIELDS,
graphql::CLEAR_FIELD,
graphql::UPDATE_FIELD_WHOLE,
graphql::CLEAR_FIELD_WHOLE,
]
.contains(&document)
{
"fields"
} else if [graphql::ADD_BLOCKED_BY, graphql::REMOVE_BLOCKED_BY].contains(&document) {
"dependencies"
} else if [graphql::ADD_SUB_ISSUE, graphql::REMOVE_SUB_ISSUE].contains(&document) {
"project"
} else if document == graphql::UPDATE_DRAFT {
"draft"
} else {
return operation_description(document).to_owned();
};
part.to_owned()
}
fn stale(error: &SourceError) -> bool {
let SourceError::Refused { message } = error else {
return false;
};
let message = message.to_ascii_lowercase();
message.contains(super::UNRESOLVABLE_NODE) || message.contains("single select option")
}
fn outcome(sent: &Result<Value, SourceError>) -> PartOutcome {
match sent {
Ok(_) => PartOutcome::Landed,
Err(SourceError::Unavailable { message })
if message.starts_with("GitHub GraphQL request failed")
|| message.starts_with("GitHub GraphQL response could not be read") =>
{
PartOutcome::Unknown {
error: message.clone(),
}
}
Err(error) => PartOutcome::Rejected {
error: error.to_string(),
stale: stale(error),
},
}
}
impl GitHubProjectsSource {
pub(super) fn context_of(&self, id: &NativeId) -> Option<Value> {
let item = self.resolved_cache().ok()?.get(id).cloned()?;
if item.kind != BoardKind::Work(ItemKind::Task) {
return None;
}
let comments = held(&self.expected.learnt).get(id).cloned();
serde_json::to_value(Context {
version: CONTEXT_VERSION,
item,
comments,
})
.ok()
}
pub(super) fn board_context_held(&self) -> Option<Value> {
let held = self.fields_cache().ok()?.clone();
let fields = match held {
Some(fields) => fields,
None => self
.resolved_cache()
.ok()?
.values()
.find_map(Resolved::carried_board)?,
};
serde_json::to_value(Board {
version: CONTEXT_VERSION,
fields,
})
.ok()
}
pub(super) fn prime(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
let Ok(context) = serde_json::from_value::<Context>(context.clone()) else {
return false;
};
if context.version != CONTEXT_VERSION
|| context.item.id != *id
|| context.item.kind != BoardKind::Work(ItemKind::Task)
{
return false;
}
let mut item = context.item;
let named = item.named_board().map(|named| named.as_str().to_owned());
let board = board
.and_then(|board| serde_json::from_value::<Board>(board.clone()).ok())
.filter(|board| board.version == CONTEXT_VERSION);
if let Some(board) = &board {
if item
.named_board()
.is_some_and(|named| named.as_str() == board.fields.id.as_str())
{
item.board_fields = Some(board.fields.fields.clone());
}
}
let Ok(mut cache) = self.resolved_cache() else {
return false;
};
cache.insert(id.clone(), item);
drop(cache);
held(&self.expected.primed).insert(id.clone(), context.comments);
if let Some(board) = board
&& named.as_deref() == Some(board.fields.id.as_str())
&& let Ok(mut fields) = self.fields_cache()
&& fields.is_none()
{
*fields = Some(board.fields);
}
true
}
pub(super) fn unprime_held(&self, id: &NativeId) {
if let Ok(mut cache) = self.resolved_cache() {
cache.remove(id);
}
if held(&self.expected.primed).remove(id).is_some() {
held(&self.expected.narrowed).insert(id.clone());
if let Ok(mut fields) = self.fields_cache() {
*fields = None;
}
}
}
pub(super) async fn journal(
&self,
document: &str,
variables: &Value,
sent: &Result<Value, SourceError>,
) {
let mutation = is_mutation(document);
let mut entry = JournalEntry {
part: part_of(document),
kind: if mutation {
RequestKind::Mutation
} else {
RequestKind::Read
},
outcome: outcome(sent),
fields: BTreeMap::new(),
shown: Shown::Nothing,
};
if mutation && let Ok(data) = sent {
let (issue, fields) = read_back(document, variables, data);
entry.fields = fields;
if let Some(issue) = issue {
let version = self.token_of(issue);
entry.shown = match (self.whole_of(issue, version.as_deref()), version) {
(Some(task), Some(_)) => Shown::Whole {
task: Box::new(task),
},
(None, Some(version)) => Shown::Version { version },
(_, None) => Shown::Nothing,
};
if let Some(id) = issue.get("id").and_then(Value::as_str) {
self.expected
.learn_comments(&NativeId(id.to_owned()), issue);
}
}
}
self.expected.record(entry);
}
fn token_of(&self, issue: &Value) -> Option<String> {
let updated = optional_time(issue, "updatedAt").ok()??;
let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
let entry = self.board_entry(nodes)?;
let item = optional_time(entry, "updatedAt").ok()??;
let served = optional_time(issue, SERVED_AT).ok()??;
let parts = change_token::Parts {
issue: updated,
item,
comments: issue.pointer("/commentCount/totalCount")?.as_u64()?,
relationships: issue.pointer("/relationshipEvents/totalCount")?.as_u64()?,
repository: Repository::try_from(format!(
"{}/{}",
RepositoryTarget::HOST,
issue.pointer("/repository/nameWithOwner")?.as_str()?
))
.ok()?,
};
change_token::settled(parts.issue, parts.item, served).then(|| change_token::spell(&parts))
}
fn whole_of(&self, issue: &Value, version: Option<&str>) -> Option<WholeTask> {
version?;
let carried = |pointer: &str| issue.pointer(pointer).is_some();
let ended = |pointer: &str| {
issue.pointer(&format!("{pointer}/pageInfo/hasNextPage")) == Some(&Value::Bool(false))
};
let shown = WHOLE_MEMBERS.iter().all(|member| carried(member))
&& ended("/labels")
&& ended("/comments");
if !shown {
return None;
}
let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
let held = self.board_entry(nodes)?;
if held.pointer("/fieldValues/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
return None;
}
let item = json!({
"id": held.get("id")?,
"updatedAt": held.get("updatedAt"),
"project": held.get("project"),
"fieldValues": held.get("fieldValues"),
"content": issue,
});
let resolved = self.resolve(&item).ok()??;
if resolved.kind != BoardKind::Work(ItemKind::Task) {
return None;
}
let task = resolved.task().ok()?;
if task.version.as_deref() != version {
return None;
}
let page = comment_page(issue, &resolved.id.0, None).ok()?;
if page.next.is_some() {
return None;
}
let assets = AssetUploads::read(&resolved.slot)
.ok()?
.unwrap_or_default()
.0
.into_iter()
.map(|(name, upload)| onetaskgraph_plugin_api::Asset {
content_type: name.content_type(),
name,
sha256: upload.sha256,
path: None,
})
.collect();
WholeTask::new(task, page.items, assets, None)
}
}
pub const WHOLE_MEMBERS: [&str; 15] = [
"/id",
"/number",
"/title",
"/body",
"/url",
"/createdAt",
"/state",
"/stateReason",
"/repository",
"/parent",
"/subIssuesSummary",
"/labels/nodes",
"/projectItems/nodes",
"/comments/nodes",
"/updatedAt",
];
fn read_back<'a>(
document: &str,
variables: &Value,
data: &'a Value,
) -> (Option<&'a Value>, BTreeMap<String, Value>) {
let mut fields = BTreeMap::new();
let issue = if [graphql::ADD_COMMENT, graphql::ADD_COMMENT_WHOLE].contains(&document) {
if let Some(added) = data.pointer("/addComment/commentEdge/node") {
fields.insert("comment".to_owned(), comment_fields(added));
}
data.pointer("/addComment/subject")
} else if [graphql::UPDATE_COMMENT, graphql::UPDATE_COMMENT_WHOLE].contains(&document) {
let edited = data.pointer("/updateIssueComment/issueComment");
if let Some(edited) = edited {
fields.insert("comment".to_owned(), comment_fields(edited));
}
edited.and_then(|edited| edited.get("issue"))
} else if document == graphql::DELETE_COMMENT {
fields.insert(
"comment".to_owned(),
json!({"deleted": variables.pointer("/input/id")}),
);
None
} else if [graphql::UPDATE_ISSUE, graphql::UPDATE_ISSUE_WHOLE].contains(&document) {
let issue = data.pointer("/updateIssue/issue");
let input = variables.get("input");
for (asked, shown) in [
("title", "title"),
("body", "body"),
("stateInput", "state"),
] {
if input.and_then(|input| input.get(asked)).is_some() {
fields.insert(
shown.to_owned(),
issue
.and_then(|issue| issue.get(shown))
.cloned()
.unwrap_or(Value::Null),
);
}
}
issue
} else if [graphql::UPDATE_FIELD, graphql::UPDATE_FIELD_WHOLE].contains(&document) {
let item = data.pointer("/updateProjectV2ItemFieldValue/projectV2Item");
field_values(variables, item, &mut fields);
item.and_then(|item| item.get("content"))
} else if [graphql::CLEAR_FIELD, graphql::CLEAR_FIELD_WHOLE].contains(&document) {
let item = data.pointer("/clearProjectV2ItemFieldValue/projectV2Item");
field_values(variables, item, &mut fields);
item.and_then(|item| item.get("content"))
} else if document == graphql::UPDATE_FIELDS {
for argument in ["input", "second", "third", "fourth", "fifth", "sixth"] {
if let Some(field) = variables
.pointer(&format!("/{argument}/fieldId"))
.and_then(Value::as_str)
{
fields.insert(field.to_owned(), Value::Null);
}
}
None
} else {
None
};
(issue.filter(|issue| !issue.is_null()), fields)
}
fn comment_fields(comment: &Value) -> Value {
json!({
"id": comment.get("id"),
"url": comment.get("url"),
"body": comment.get("body"),
})
}
fn field_values(variables: &Value, item: Option<&Value>, fields: &mut BTreeMap<String, Value>) {
let Some(field) = variables.pointer("/input/fieldId").and_then(Value::as_str) else {
return;
};
let memberships = item
.and_then(|item| item.pointer("/content/projectItems/nodes"))
.and_then(Value::as_array);
let item_id = item.and_then(|item| item.get("id")).and_then(Value::as_str);
let shown = memberships.and_then(|nodes| {
let entry = nodes
.iter()
.find(|node| node.get("id").and_then(Value::as_str) == item_id)?;
entry
.pointer("/fieldValues/nodes")?
.as_array()?
.iter()
.find(|value| value.pointer("/field/id").and_then(Value::as_str) == Some(field))
});
match shown {
Some(value) => {
let name = value
.pointer("/field/name")
.and_then(Value::as_str)
.unwrap_or(field)
.to_owned();
let held = value
.get("name")
.or_else(|| value.get("text"))
.cloned()
.unwrap_or(Value::Null);
fields.insert(name, held);
}
None => {
fields.insert(field.to_owned(), Value::Null);
}
}
}