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 = 2;
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 narrowed: Mutex<std::collections::BTreeSet<NativeId>>,
82}
83
84fn held<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
87 lock.lock()
88 .unwrap_or_else(std::sync::PoisonError::into_inner)
89}
90
91impl Held {
92 pub(super) fn journaling(&self) -> bool {
94 held(&self.journal).is_some()
95 }
96
97 pub(super) fn begin(&self) {
99 *held(&self.journal) = Some(WriteJournal::default());
100 }
101
102 pub(super) fn end(&self) -> WriteJournal {
104 held(&self.journal).take().unwrap_or_default()
105 }
106
107 fn record(&self, entry: JournalEntry) {
108 if let Some(journal) = held(&self.journal).as_mut() {
109 journal.requests.push(entry);
110 }
111 }
112
113 pub(super) fn learn_comments(&self, issue: &NativeId, node: &Value) {
115 let Some(connection) = node.get("comments") else {
116 return;
117 };
118 if connection.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
119 return;
120 }
121 let Some(nodes) = connection.get("nodes").and_then(Value::as_array) else {
122 return;
123 };
124 let Some(comments) = nodes
128 .iter()
129 .map(|comment| {
130 Some((
131 NativeId(comment.get("id")?.as_str()?.to_owned()),
132 comment.get("body")?.as_str()?.to_owned(),
133 ))
134 })
135 .collect::<Option<BTreeMap<_, _>>>()
136 else {
137 held(&self.learnt).remove(issue);
138 return;
139 };
140 held(&self.learnt).insert(issue.clone(), comments);
141 }
142
143 pub(super) fn is_primed(&self, id: &NativeId) -> bool {
146 held(&self.primed).contains_key(id) || held(&self.narrowed).contains(id)
147 }
148
149 pub(super) fn end_command(&self) {
151 held(&self.narrowed).clear();
152 }
153
154 pub(super) fn primed_comment(
158 &self,
159 issue: &NativeId,
160 comment: &NativeId,
161 ) -> Option<Option<String>> {
162 held(&self.primed)
163 .get(issue)
164 .and_then(Option::as_ref)
165 .map(|comments| comments.get(comment).cloned())
166 }
167}
168
169pub(super) fn whole_variables(mut variables: Value) -> Value {
171 if let Some(members) = variables.as_object_mut() {
172 members.insert("first".to_owned(), json!(MAX_PAGE_SIZE));
173 members.insert("nestedFirst".to_owned(), json!(NESTED_PAGE_SIZE));
174 members.insert("boardItems".to_owned(), json!(BOARD_ITEMS_PAGE_SIZE));
175 members.insert("duplicates".to_owned(), json!(true));
176 }
177 variables
178}
179
180fn part_of(document: &str) -> String {
182 let part = if [
183 graphql::ADD_COMMENT,
184 graphql::UPDATE_COMMENT,
185 graphql::DELETE_COMMENT,
186 graphql::ADD_COMMENT_WHOLE,
187 graphql::UPDATE_COMMENT_WHOLE,
188 ]
189 .contains(&document)
190 {
191 "comment"
192 } else if [graphql::UPDATE_ISSUE, graphql::UPDATE_ISSUE_WHOLE].contains(&document) {
193 "issue"
194 } else if [
195 graphql::UPDATE_FIELD,
196 graphql::UPDATE_FIELDS,
197 graphql::CLEAR_FIELD,
198 graphql::UPDATE_FIELD_WHOLE,
199 graphql::CLEAR_FIELD_WHOLE,
200 ]
201 .contains(&document)
202 {
203 "fields"
204 } else if [graphql::ADD_BLOCKED_BY, graphql::REMOVE_BLOCKED_BY].contains(&document) {
205 "dependencies"
206 } else if [graphql::ADD_SUB_ISSUE, graphql::REMOVE_SUB_ISSUE].contains(&document) {
207 "project"
208 } else if document == graphql::UPDATE_DRAFT {
209 "draft"
210 } else {
211 return operation_description(document).to_owned();
212 };
213 part.to_owned()
214}
215
216fn stale(error: &SourceError) -> bool {
219 let SourceError::Refused { message } = error else {
220 return false;
221 };
222 let message = message.to_ascii_lowercase();
223 message.contains(super::UNRESOLVABLE_NODE) || message.contains("single select option")
224}
225
226fn outcome(sent: &Result<Value, SourceError>) -> PartOutcome {
228 match sent {
229 Ok(_) => PartOutcome::Landed,
230 Err(SourceError::Unavailable { message })
233 if message.starts_with("GitHub GraphQL request failed")
234 || message.starts_with("GitHub GraphQL response could not be read") =>
235 {
236 PartOutcome::Unknown {
237 error: message.clone(),
238 }
239 }
240 Err(error) => PartOutcome::Rejected {
241 error: error.to_string(),
242 stale: stale(error),
243 },
244 }
245}
246
247impl GitHubProjectsSource {
248 pub(super) fn context_of(&self, id: &NativeId) -> Option<Value> {
251 let item = self.resolved_cache().ok()?.get(id).cloned()?;
252 if item.kind != BoardKind::Work(ItemKind::Task) {
253 return None;
254 }
255 let comments = held(&self.expected.learnt).get(id).cloned();
256 serde_json::to_value(Context {
257 version: CONTEXT_VERSION,
258 item,
259 comments,
260 })
261 .ok()
262 }
263
264 pub(super) fn board_context_held(&self) -> Option<Value> {
268 let held = self.fields_cache().ok()?.clone();
269 let fields = match held {
270 Some(fields) => fields,
271 None => self
272 .resolved_cache()
273 .ok()?
274 .values()
275 .find_map(Resolved::carried_board)?,
276 };
277 serde_json::to_value(Board {
278 version: CONTEXT_VERSION,
279 fields,
280 })
281 .ok()
282 }
283
284 pub(super) fn prime(&self, id: &NativeId, context: &Value, board: Option<&Value>) -> bool {
286 let Ok(context) = serde_json::from_value::<Context>(context.clone()) else {
287 return false;
288 };
289 if context.version != CONTEXT_VERSION
292 || context.item.id != *id
293 || context.item.kind != BoardKind::Work(ItemKind::Task)
294 {
295 return false;
296 }
297 let mut item = context.item;
298 let named = item.named_board().map(|named| named.as_str().to_owned());
299 let board = board
300 .and_then(|board| serde_json::from_value::<Board>(board.clone()).ok())
301 .filter(|board| board.version == CONTEXT_VERSION);
302 if let Some(board) = &board {
303 if item
307 .named_board()
308 .is_some_and(|named| named.as_str() == board.fields.id.as_str())
309 {
310 item.board_fields = Some(board.fields.fields.clone());
311 }
312 }
313 let Ok(mut cache) = self.resolved_cache() else {
314 return false;
315 };
316 cache.insert(id.clone(), item);
317 drop(cache);
318 held(&self.expected.primed).insert(id.clone(), context.comments);
319 if let Some(board) = board
321 && named.as_deref() == Some(board.fields.id.as_str())
322 && let Ok(mut fields) = self.fields_cache()
323 && fields.is_none()
324 {
325 *fields = Some(board.fields);
326 }
327 true
328 }
329
330 pub(super) fn unprime_held(&self, id: &NativeId) {
332 if let Ok(mut cache) = self.resolved_cache() {
333 cache.remove(id);
334 }
335 if held(&self.expected.primed).remove(id).is_some() {
336 held(&self.expected.narrowed).insert(id.clone());
337 if let Ok(mut fields) = self.fields_cache() {
338 *fields = None;
339 }
340 }
341 }
342
343 pub(super) async fn journal(
345 &self,
346 document: &str,
347 variables: &Value,
348 sent: &Result<Value, SourceError>,
349 ) {
350 let mutation = is_mutation(document);
351 let mut entry = JournalEntry {
352 part: part_of(document),
353 kind: if mutation {
354 RequestKind::Mutation
355 } else {
356 RequestKind::Read
357 },
358 outcome: outcome(sent),
359 fields: BTreeMap::new(),
360 shown: Shown::Nothing,
361 };
362 if mutation && let Ok(data) = sent {
363 let (issue, fields) = read_back(document, variables, data);
364 entry.fields = fields;
365 if let Some(issue) = issue {
366 let version = self.token_of(issue);
367 entry.shown = match (self.whole_of(issue, version.as_deref()), version) {
368 (Some(task), Some(_)) => Shown::Whole {
369 task: Box::new(task),
370 },
371 (None, Some(version)) => Shown::Version { version },
372 (_, None) => Shown::Nothing,
373 };
374 if let Some(id) = issue.get("id").and_then(Value::as_str) {
377 self.expected
378 .learn_comments(&NativeId(id.to_owned()), issue);
379 }
380 }
381 }
382 self.expected.record(entry);
383 }
384
385 fn token_of(&self, issue: &Value) -> Option<String> {
389 let updated = optional_time(issue, "updatedAt").ok()??;
390 let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
391 let entry = self.board_entry(nodes)?;
392 let item = optional_time(entry, "updatedAt").ok()??;
393 let served = optional_time(issue, SERVED_AT).ok()??;
394 let parts = change_token::Parts {
395 issue: updated,
396 item,
397 comments: issue.pointer("/commentCount/totalCount")?.as_u64()?,
398 relationships: issue.pointer("/relationshipEvents/totalCount")?.as_u64()?,
399 facets: facets_of(issue, entry.get("fieldValues")?)?,
400 repository: Repository::try_from(format!(
401 "{}/{}",
402 RepositoryTarget::HOST,
403 issue.pointer("/repository/nameWithOwner")?.as_str()?
404 ))
405 .ok()?,
406 };
407 change_token::settled(parts.issue, parts.item, served).then(|| change_token::spell(&parts))
408 }
409
410 fn whole_of(&self, issue: &Value, version: Option<&str>) -> Option<WholeTask> {
418 version?;
419 let carried = |pointer: &str| issue.pointer(pointer).is_some();
420 let ended = |pointer: &str| {
421 issue.pointer(&format!("{pointer}/pageInfo/hasNextPage")) == Some(&Value::Bool(false))
422 };
423 let shown = WHOLE_MEMBERS.iter().all(|member| carried(member))
424 && ended("/labels")
425 && ended("/comments");
426 if !shown {
427 return None;
428 }
429 let nodes = issue.pointer("/projectItems/nodes")?.as_array()?;
430 let held = self.board_entry(nodes)?;
431 if held.pointer("/fieldValues/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
432 return None;
433 }
434 let item = json!({
435 "id": held.get("id")?,
436 "updatedAt": held.get("updatedAt"),
437 "project": held.get("project"),
438 "fieldValues": held.get("fieldValues"),
439 "content": issue,
440 });
441 let resolved = self.resolve(&item).ok()??;
442 if resolved.kind != BoardKind::Work(ItemKind::Task) {
443 return None;
444 }
445 let task = resolved.task().ok()?;
446 if task.version.as_deref() != version {
447 return None;
448 }
449 let page = comment_page(issue, &resolved.id.0, None).ok()?;
450 if page.next.is_some() {
451 return None;
452 }
453 let assets = AssetUploads::read(&resolved.slot)
454 .ok()?
455 .unwrap_or_default()
456 .0
457 .into_iter()
458 .map(|(name, upload)| onetaskgraph_plugin_api::Asset {
459 content_type: name.content_type(),
460 name,
461 sha256: upload.sha256,
462 path: None,
463 })
464 .collect();
465 WholeTask::new(task, page.items, assets, None)
466 }
467}
468
469pub const WHOLE_MEMBERS: [&str; 15] = [
474 "/id",
475 "/number",
476 "/title",
477 "/body",
478 "/url",
479 "/createdAt",
480 "/state",
481 "/stateReason",
482 "/repository",
483 "/parent",
484 "/subIssuesSummary",
485 "/labels/nodes",
486 "/projectItems/nodes",
487 "/comments/nodes",
488 "/updatedAt",
489];
490
491fn read_back<'a>(
494 document: &str,
495 variables: &Value,
496 data: &'a Value,
497) -> (Option<&'a Value>, BTreeMap<String, Value>) {
498 let mut fields = BTreeMap::new();
499 let issue = if [graphql::ADD_COMMENT, graphql::ADD_COMMENT_WHOLE].contains(&document) {
500 if let Some(added) = data.pointer("/addComment/commentEdge/node") {
501 fields.insert("comment".to_owned(), comment_fields(added));
502 }
503 data.pointer("/addComment/subject")
504 } else if [graphql::UPDATE_COMMENT, graphql::UPDATE_COMMENT_WHOLE].contains(&document) {
505 let edited = data.pointer("/updateIssueComment/issueComment");
506 if let Some(edited) = edited {
507 fields.insert("comment".to_owned(), comment_fields(edited));
508 }
509 edited.and_then(|edited| edited.get("issue"))
510 } else if document == graphql::DELETE_COMMENT {
511 fields.insert(
512 "comment".to_owned(),
513 json!({"deleted": variables.pointer("/input/id")}),
514 );
515 None
516 } else if [graphql::UPDATE_ISSUE, graphql::UPDATE_ISSUE_WHOLE].contains(&document) {
517 let issue = data.pointer("/updateIssue/issue");
518 let input = variables.get("input");
519 for (asked, shown) in [
520 ("title", "title"),
521 ("body", "body"),
522 ("stateInput", "state"),
523 ] {
524 if input.and_then(|input| input.get(asked)).is_some() {
525 fields.insert(
526 shown.to_owned(),
527 issue
528 .and_then(|issue| issue.get(shown))
529 .cloned()
530 .unwrap_or(Value::Null),
531 );
532 }
533 }
534 issue
535 } else if [graphql::UPDATE_FIELD, graphql::UPDATE_FIELD_WHOLE].contains(&document) {
536 let item = data.pointer("/updateProjectV2ItemFieldValue/projectV2Item");
537 field_values(variables, item, &mut fields);
538 item.and_then(|item| item.get("content"))
539 } else if [graphql::CLEAR_FIELD, graphql::CLEAR_FIELD_WHOLE].contains(&document) {
540 let item = data.pointer("/clearProjectV2ItemFieldValue/projectV2Item");
541 field_values(variables, item, &mut fields);
542 item.and_then(|item| item.get("content"))
543 } else if document == graphql::UPDATE_FIELDS {
544 for argument in ["input", "second", "third", "fourth", "fifth", "sixth"] {
547 if let Some(field) = variables
548 .pointer(&format!("/{argument}/fieldId"))
549 .and_then(Value::as_str)
550 {
551 fields.insert(field.to_owned(), Value::Null);
552 }
553 }
554 None
555 } else {
556 None
557 };
558 (issue.filter(|issue| !issue.is_null()), fields)
559}
560
561fn comment_fields(comment: &Value) -> Value {
563 json!({
564 "id": comment.get("id"),
565 "url": comment.get("url"),
566 "body": comment.get("body"),
567 })
568}
569
570fn field_values(variables: &Value, item: Option<&Value>, fields: &mut BTreeMap<String, Value>) {
573 let Some(field) = variables.pointer("/input/fieldId").and_then(Value::as_str) else {
574 return;
575 };
576 let memberships = item
577 .and_then(|item| item.pointer("/content/projectItems/nodes"))
578 .and_then(Value::as_array);
579 let item_id = item.and_then(|item| item.get("id")).and_then(Value::as_str);
580 let shown = memberships.and_then(|nodes| {
581 let entry = nodes
582 .iter()
583 .find(|node| node.get("id").and_then(Value::as_str) == item_id)?;
584 entry
585 .pointer("/fieldValues/nodes")?
586 .as_array()?
587 .iter()
588 .find(|value| value.pointer("/field/id").and_then(Value::as_str) == Some(field))
589 });
590 match shown {
591 Some(value) => {
592 let name = value
593 .pointer("/field/name")
594 .and_then(Value::as_str)
595 .unwrap_or(field)
596 .to_owned();
597 let held = value
598 .get("name")
599 .or_else(|| value.get("text"))
600 .cloned()
601 .unwrap_or(Value::Null);
602 fields.insert(name, held);
603 }
604 None => {
605 fields.insert(field.to_owned(), Value::Null);
608 }
609 }
610}