1use std::collections::BTreeMap;
19use std::sync::{Mutex, MutexGuard};
20
21use onetaskgraph_plugin_api::{
22 AssetUploads, JournalEntry, NativeId, PartOutcome, Repository, RequestKind, Shown, SourceError,
23 WholeTask, WriteJournal,
24};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use super::{
29 BOARD_ITEMS_PAGE_SIZE, BoardFields, BoardKind, GitHubProjectsSource, ItemKind, MAX_PAGE_SIZE,
30 NESTED_PAGE_SIZE, RepositoryTarget, Resolved, SERVED_AT, change_token, comment_page, facets_of,
31 graphql, is_mutation, operation_description, optional_time,
32};
33
34pub const CONTEXT_VERSION: u64 = 4;
40
41#[must_use]
43pub fn context_shapes() -> Value {
44 json!({
45 "task": schemars::schema_for!(Context),
46 "board": schemars::schema_for!(Board),
47 })
48}
49
50#[derive(Serialize, Deserialize, schemars::JsonSchema)]
53struct Context {
54 version: u64,
55 item: Resolved,
56 comments: Option<BTreeMap<NativeId, String>>,
60}
61
62#[derive(Serialize, Deserialize, schemars::JsonSchema)]
64struct Board {
65 version: u64,
66 fields: BoardFields,
67}
68
69#[derive(Default)]
71pub(super) struct Held {
72 journal: Mutex<Option<WriteJournal>>,
74 learnt: Mutex<BTreeMap<NativeId, BTreeMap<NativeId, String>>>,
76 primed: Mutex<BTreeMap<NativeId, Option<BTreeMap<NativeId, String>>>>,
78 documents: Mutex<BTreeMap<NativeId, Resolved>>,
82 narrowed: Mutex<std::collections::BTreeSet<NativeId>>,
86}
87
88fn held<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
91 lock.lock()
92 .unwrap_or_else(std::sync::PoisonError::into_inner)
93}
94
95impl Held {
96 pub(super) fn journaling(&self) -> bool {
98 held(&self.journal).is_some()
99 }
100
101 pub(super) fn begin(&self) {
103 *held(&self.journal) = Some(WriteJournal::default());
104 }
105
106 pub(super) fn end(&self) -> WriteJournal {
108 held(&self.journal).take().unwrap_or_default()
109 }
110
111 fn record(&self, entry: JournalEntry) {
112 if let Some(journal) = held(&self.journal).as_mut() {
113 journal.requests.push(entry);
114 }
115 }
116
117 pub(super) fn learn_comments(&self, issue: &NativeId, node: &Value) {
119 let Some(connection) = node.get("comments") else {
120 return;
121 };
122 if connection.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
123 return;
124 }
125 let Some(nodes) = connection.get("nodes").and_then(Value::as_array) else {
126 return;
127 };
128 let Some(comments) = nodes
132 .iter()
133 .map(|comment| {
134 Some((
135 NativeId(comment.get("id")?.as_str()?.to_owned()),
136 comment.get("body")?.as_str()?.to_owned(),
137 ))
138 })
139 .collect::<Option<BTreeMap<_, _>>>()
140 else {
141 held(&self.learnt).remove(issue);
142 return;
143 };
144 held(&self.learnt).insert(issue.clone(), comments);
145 }
146
147 pub(super) fn end_command(&self) {
150 held(&self.narrowed).clear();
151 held(&self.documents).clear();
152 }
153
154 pub(super) fn read_document(&self, item: &Resolved) {
157 if item.kind == BoardKind::Document {
158 held(&self.documents).insert(item.id.clone(), item.clone());
159 }
160 }
161
162 pub(super) fn primed_comment(
166 &self,
167 issue: &NativeId,
168 comment: &NativeId,
169 ) -> Option<Option<String>> {
170 held(&self.primed)
171 .get(issue)
172 .and_then(Option::as_ref)
173 .map(|comments| comments.get(comment).cloned())
174 }
175}
176
177pub(super) fn whole_variables(mut variables: Value) -> Value {
179 if let Some(members) = variables.as_object_mut() {
180 members.insert("first".to_owned(), json!(MAX_PAGE_SIZE));
181 members.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
182 members.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
183 members.insert("duplicates".to_owned(), json!(true));
184 }
185 variables
186}
187
188fn part_of(document: &str) -> String {
190 let part = if [
191 graphql::ADD_COMMENT,
192 graphql::UPDATE_COMMENT,
193 graphql::DELETE_COMMENT,
194 graphql::ADD_COMMENT_WHOLE,
195 graphql::UPDATE_COMMENT_WHOLE,
196 ]
197 .contains(&document)
198 {
199 "comment"
200 } else if [graphql::UPDATE_ISSUE, graphql::UPDATE_ISSUE_WHOLE].contains(&document) {
201 "issue"
202 } else if [
203 graphql::UPDATE_FIELD,
204 graphql::UPDATE_FIELDS,
205 graphql::CLEAR_FIELD,
206 graphql::UPDATE_FIELD_WHOLE,
207 graphql::CLEAR_FIELD_WHOLE,
208 ]
209 .contains(&document)
210 {
211 "fields"
212 } else if [graphql::ADD_BLOCKED_BY, graphql::REMOVE_BLOCKED_BY].contains(&document) {
213 "dependencies"
214 } else if [graphql::ADD_SUB_ISSUE, graphql::REMOVE_SUB_ISSUE].contains(&document) {
215 "project"
216 } else if document == graphql::UPDATE_DRAFT {
217 "draft"
218 } else {
219 return operation_description(document).to_owned();
220 };
221 part.to_owned()
222}
223
224fn stale(error: &SourceError) -> bool {
227 let SourceError::Refused { message } = error else {
228 return false;
229 };
230 let message = message.to_ascii_lowercase();
231 message.contains(super::UNRESOLVABLE_NODE) || message.contains("single select option")
232}
233
234fn outcome(sent: &Result<Value, SourceError>) -> PartOutcome {
236 match sent {
237 Ok(_) => PartOutcome::Landed,
238 Err(SourceError::Unavailable { message })
241 if message.starts_with("GitHub GraphQL request failed")
242 || message.starts_with("GitHub GraphQL response could not be read") =>
243 {
244 PartOutcome::Unknown {
245 error: message.clone(),
246 }
247 }
248 Err(error) => PartOutcome::Rejected {
249 error: error.to_string(),
250 stale: stale(error),
251 },
252 }
253}
254
255impl GitHubProjectsSource {
256 pub(super) fn context_of(&self, id: &NativeId) -> Option<Value> {
260 let task = self
261 .resolved_cache()
262 .ok()?
263 .get(id)
264 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
265 .cloned();
266 let Some(item) = task else {
267 let item = held(&self.expected.documents).get(id).cloned()?;
268 return serde_json::to_value(Context {
269 version: CONTEXT_VERSION,
270 item,
271 comments: None,
272 })
273 .ok();
274 };
275 let comments = held(&self.expected.learnt).get(id).cloned();
276 serde_json::to_value(Context {
277 version: CONTEXT_VERSION,
278 item,
279 comments,
280 })
281 .ok()
282 }
283
284 pub(super) fn board_context_held(&self) -> Option<Value> {
288 let held = self.fields_cache().ok()?.clone();
289 let fields = match held {
290 Some(fields) => fields,
291 None => self
292 .resolved_cache()
293 .ok()?
294 .values()
295 .find_map(Resolved::carried_board)?,
296 };
297 serde_json::to_value(Board {
298 version: CONTEXT_VERSION,
299 fields,
300 })
301 .ok()
302 }
303
304 pub(super) fn prime(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
306 let Ok(context) = serde_json::from_value::<Context>(context.clone()) else {
307 return false;
308 };
309 if context.version != CONTEXT_VERSION
313 || context.item.id != *id
314 || !matches!(
315 context.item.kind,
316 BoardKind::Work(ItemKind::Task) | BoardKind::Document
317 )
318 {
319 return false;
320 }
321 let mut item = context.item;
322 let named = item.named_board().map(|named| named.as_str().to_owned());
323 let board = board
324 .and_then(|board| serde_json::from_value::<Board>(board.clone()).ok())
325 .filter(|board| board.version == CONTEXT_VERSION);
326 if let Some(board) = &board {
327 if item
331 .named_board()
332 .is_some_and(|named| named.as_str() == board.fields.id.as_str())
333 {
334 item.board_fields = Some(board.fields.fields.clone());
335 }
336 }
337 let Ok(mut cache) = self.resolved_cache() else {
338 return false;
339 };
340 cache.insert(id.clone(), item);
341 drop(cache);
342 held(&self.expected.primed).insert(id.clone(), context.comments);
343 if let Some(board) = board
345 && named.as_deref() == Some(board.fields.id.as_str())
346 && let Ok(mut fields) = self.fields_cache()
347 && fields.is_none()
348 {
349 *fields = Some(board.fields);
350 }
351 true
352 }
353
354 pub(super) fn unprime_held(&self, id: &NativeId) {
356 if let Ok(mut cache) = self.resolved_cache() {
357 cache.remove(id);
358 }
359 if held(&self.expected.primed).remove(id).is_some() {
360 held(&self.expected.narrowed).insert(id.clone());
361 if let Ok(mut fields) = self.fields_cache() {
362 *fields = None;
363 }
364 }
365 }
366
367 pub(super) async fn journal(
369 &self,
370 document: &str,
371 variables: &Value,
372 sent: &Result<Value, SourceError>,
373 ) {
374 let mutation = is_mutation(document);
375 let mut entry = JournalEntry {
376 part: part_of(document),
377 kind: if mutation {
378 RequestKind::Mutation
379 } else {
380 RequestKind::Read
381 },
382 outcome: outcome(sent),
383 fields: BTreeMap::new(),
384 shown: Shown::Nothing,
385 };
386 if mutation && let Ok(data) = sent {
387 let (issue, fields) = read_back(document, variables, data);
388 entry.fields = fields;
389 if let Some(issue) = issue {
390 let version = self.token_of(issue);
391 entry.shown = match (self.whole_of(issue, version.as_deref()), version) {
392 (Some(task), Some(_)) => Shown::Whole {
393 task: Box::new(task),
394 },
395 (None, Some(version)) => Shown::Version { version },
396 (_, None) => Shown::Nothing,
397 };
398 if let Some(id) = issue.get("id").and_then(Value::as_str) {
401 self.expected
402 .learn_comments(&NativeId(id.to_owned()), issue);
403 }
404 }
405 }
406 self.expected.record(entry);
407 }
408
409 fn token_of(&self, issue: &Value) -> Option<String> {
413 let served = optional_time(issue, SERVED_AT).ok()??;
414 let (_, parts) = self.token_parts(issue)?;
415 change_token::settled(parts.issue, parts.item, served).then(|| change_token::spell(&parts))
416 }
417
418 pub(super) fn written_token_of(&self, issue: &Value) -> Option<(String, String)> {
426 let (item, parts) = self.token_parts(issue)?;
427 Some((item, change_token::spell(&parts)))
428 }
429
430 fn token_parts(&self, issue: &Value) -> Option<(String, change_token::Parts)> {
433 let updated = optional_time(issue, "updatedAt").ok()??;
434 let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
435 let entry = self.board_entry(nodes)?;
436 let item = optional_time(entry, "updatedAt").ok()??;
437 let parts = change_token::Parts {
438 issue: updated,
439 item,
440 comments: issue.pointer("/commentCount/totalCount")?.as_u64()?,
441 relationships: issue.pointer("/relationshipEvents/totalCount")?.as_u64()?,
442 facets: facets_of(issue, entry.get("fieldValues")?)?,
443 repository: Repository::try_from(format!(
444 "{}/{}",
445 RepositoryTarget::HOST,
446 issue.pointer("/repository/nameWithOwner")?.as_str()?
447 ))
448 .ok()?,
449 };
450 Some((entry.get("id")?.as_str()?.to_owned(), parts))
451 }
452
453 fn whole_of(&self, issue: &Value, version: Option<&str>) -> Option<WholeTask> {
461 version?;
462 let carried = |pointer: &str| issue.pointer(pointer).is_some();
463 let ended = |pointer: &str| {
464 issue.pointer(&format!("{pointer}/pageInfo/hasNextPage")) == Some(&Value::Bool(false))
465 };
466 let shown = WHOLE_MEMBERS.iter().all(|member| carried(member))
467 && ended("/labels")
468 && ended("/comments");
469 if !shown {
470 return None;
471 }
472 let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
473 let held = self.board_entry(nodes)?;
474 if held.pointer("/fieldValues/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
475 return None;
476 }
477 let item = json!({
478 "id": held.get("id")?,
479 "updatedAt": held.get("updatedAt"),
480 "project": held.get("project"),
481 "fieldValues": held.get("fieldValues"),
482 "content": issue,
483 });
484 let resolved = self.resolve(&item).ok()??;
485 if resolved.kind != BoardKind::Work(ItemKind::Task) {
486 return None;
487 }
488 let task = resolved.task().ok()?;
489 if task.version.as_deref() != version {
490 return None;
491 }
492 let page = comment_page(issue, &resolved.id.0, None).ok()?;
493 if page.next.is_some() {
494 return None;
495 }
496 let assets = AssetUploads::read(&resolved.slot)
497 .ok()?
498 .unwrap_or_default()
499 .0
500 .into_iter()
501 .map(|(name, upload)| onetaskgraph_plugin_api::Asset {
502 content_type: name.content_type(),
503 name,
504 sha256: upload.sha256,
505 path: None,
506 })
507 .collect();
508 WholeTask::new(task, page.items, assets, None)
509 }
510}
511
512pub const WHOLE_MEMBERS: [&str; 15] = [
517 "/id",
518 "/number",
519 "/title",
520 "/body",
521 "/url",
522 "/createdAt",
523 "/state",
524 "/stateReason",
525 "/repository",
526 "/parent",
527 "/subIssuesSummary",
528 "/labels/nodes",
529 "/projectItems/nodes",
530 "/comments/nodes",
531 "/updatedAt",
532];
533
534pub(super) fn read_back<'a>(
537 document: &str,
538 variables: &Value,
539 data: &'a Value,
540) -> (Option<&'a Value>, BTreeMap<String, Value>) {
541 let mut fields = BTreeMap::new();
542 let issue = if [graphql::ADD_COMMENT, graphql::ADD_COMMENT_WHOLE].contains(&document) {
543 if let Some(added) = data.pointer("/addComment/commentEdge/node") {
544 fields.insert("comment".to_owned(), comment_fields(added));
545 }
546 data.pointer("/addComment/subject")
547 } else if [graphql::UPDATE_COMMENT, graphql::UPDATE_COMMENT_WHOLE].contains(&document) {
548 let edited = data.pointer("/updateIssueComment/issueComment");
549 if let Some(edited) = edited {
550 fields.insert("comment".to_owned(), comment_fields(edited));
551 }
552 edited.and_then(|edited| edited.get("issue"))
553 } else if document == graphql::DELETE_COMMENT {
554 fields.insert(
555 "comment".to_owned(),
556 json!({"deleted": variables.pointer("/input/id")}),
557 );
558 None
559 } else if [
560 graphql::UPDATE_ISSUE,
561 graphql::UPDATE_ISSUE_WHOLE,
562 graphql::UPDATE_ISSUE_TOKEN,
563 ]
564 .contains(&document)
565 {
566 let issue = data.pointer("/updateIssue/issue");
567 let input = variables.get("input");
568 for (asked, shown) in [
569 ("title", "title"),
570 ("body", "body"),
571 ("stateInput", "state"),
572 ] {
573 if input.and_then(|input| input.get(asked)).is_some() {
574 fields.insert(
575 shown.to_owned(),
576 issue
577 .and_then(|issue| issue.get(shown))
578 .cloned()
579 .unwrap_or(Value::Null),
580 );
581 }
582 }
583 issue
584 } else if [
585 graphql::UPDATE_FIELD,
586 graphql::UPDATE_FIELD_WHOLE,
587 graphql::UPDATE_FIELD_TOKEN,
588 ]
589 .contains(&document)
590 {
591 let item = data.pointer("/updateProjectV2ItemFieldValue/projectV2Item");
592 field_values(variables, item, &mut fields);
593 item.and_then(|item| item.get("content"))
594 } else if [
595 graphql::CLEAR_FIELD,
596 graphql::CLEAR_FIELD_WHOLE,
597 graphql::CLEAR_FIELD_TOKEN,
598 ]
599 .contains(&document)
600 {
601 let item = data.pointer("/clearProjectV2ItemFieldValue/projectV2Item");
602 field_values(variables, item, &mut fields);
603 item.and_then(|item| item.get("content"))
604 } else if document == graphql::UPDATE_FIELDS {
605 for argument in ["input", "second", "third", "fourth", "fifth", "sixth"] {
608 if let Some(field) = variables
609 .pointer(&format!("/{argument}/fieldId"))
610 .and_then(Value::as_str)
611 {
612 fields.insert(field.to_owned(), Value::Null);
613 }
614 }
615 None
616 } else {
617 None
618 };
619 (issue.filter(|issue| !issue.is_null()), fields)
620}
621
622fn comment_fields(comment: &Value) -> Value {
624 json!({
625 "id": comment.get("id"),
626 "url": comment.get("url"),
627 "body": comment.get("body"),
628 })
629}
630
631fn field_values(variables: &Value, item: Option<&Value>, fields: &mut BTreeMap<String, Value>) {
634 let Some(field) = variables.pointer("/input/fieldId").and_then(Value::as_str) else {
635 return;
636 };
637 let memberships = item
638 .and_then(|item| item.pointer("/content/projectItems/nodes"))
639 .and_then(Value::as_array);
640 let item_id = item.and_then(|item| item.get("id")).and_then(Value::as_str);
641 let shown = memberships.and_then(|nodes| {
642 let entry = nodes
643 .iter()
644 .find(|node| node.get("id").and_then(Value::as_str) == item_id)?;
645 entry
646 .pointer("/fieldValues/nodes")?
647 .as_array()?
648 .iter()
649 .find(|value| value.pointer("/field/id").and_then(Value::as_str) == Some(field))
650 });
651 match shown {
652 Some(value) => {
653 let name = value
654 .pointer("/field/name")
655 .and_then(Value::as_str)
656 .unwrap_or(field)
657 .to_owned();
658 let held = value
659 .get("name")
660 .or_else(|| value.get("text"))
661 .cloned()
662 .unwrap_or(Value::Null);
663 fields.insert(name, held);
664 }
665 None => {
666 fields.insert(field.to_owned(), Value::Null);
669 }
670 }
671}