1#![deny(missing_docs)]
120
121use std::collections::BTreeMap;
122use std::sync::Mutex;
123
124use chrono::{DateTime, Utc};
125use onetaskgraph_plugin_api::{
126 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
127 Direction, Health, ItemKind, ItemWrite, Label, LabelFilter, NativeId, Page, PageRequest,
128 Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError, SourceName,
129 SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskSource, TextFields,
130 TextQuery, WriteSupport,
131};
132use reqwest::{Client, StatusCode, Url};
133use schemars::{Schema, schema_for};
134use secrecy::{ExposeSecret, SecretString};
135use serde::Deserialize;
136use serde_json::{Value, json};
137
138pub const KIND: &str = "github-projects";
140pub const MAX_PAGE_SIZE: u32 = 100;
142const NESTED_PAGE_SIZE: u32 = 50;
144
145pub mod graphql {
152 pub const BOARD: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
154 owner:repositoryOwner(login:$owner){
155 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
156 }
157 } fragment Board on ProjectV2 { id title
158 fields(first:$nestedFirst){nodes{
159 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
160 ... on ProjectV2Field{__typename id name}
161 }pageInfo{hasNextPage}}
162 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
163 ... on ProjectV2ItemFieldSingleSelectValue{name field{
164 ... on ProjectV2SingleSelectField{id name options{id name}}
165 }}
166 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
167 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
168 }pageInfo{hasNextPage}} content{
169 ... on Issue{__typename id title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
170 ... on PullRequest{__typename id}
171 ... on DraftIssue{__typename id title body createdAt updatedAt}
172 }} pageInfo{hasNextPage endCursor}}
173 }"#;
174 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
176 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
178 ... on Issue{
179 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
180 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
181 }}} fragment Related on Issue{id body parent{id} subIssuesSummary{total}}"#;
182 pub const CREATE_ISSUE: &str =
184 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id}}}"#;
185 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
187 pub const UPDATE_ISSUE: &str =
189 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
190 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
192 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
194 pub const ADD_SUB_ISSUE: &str =
196 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
197 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
199 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
201 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
203 pub const DELETE_ISSUE: &str =
209 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
210}
211
212fn default_token_env() -> String {
213 "GH_PROJECTS_TOKEN".to_owned()
214}
215fn default_endpoint() -> String {
216 "https://api.github.com/graphql".to_owned()
217}
218
219#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
224#[serde(untagged)]
225pub enum StatusTargetConfig {
226 Column(ColumnName),
228 Closed {
230 closed: ClosedState,
232 },
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
240#[serde(try_from = "String")]
241pub struct ColumnName(String);
242
243impl ColumnName {
244 fn as_str(&self) -> &str {
246 &self.0
247 }
248}
249
250impl TryFrom<String> for ColumnName {
251 type Error = String;
252
253 fn try_from(name: String) -> Result<Self, Self::Error> {
254 if name.trim().is_empty() {
255 return Err("a status_mapping option name cannot be blank".to_owned());
256 }
257 Ok(Self(name))
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
266#[serde(rename_all = "kebab-case")]
267pub enum ClosedState {
268 Completed,
270 NotPlanned,
272}
273
274impl ClosedState {
275 const fn reason(self) -> &'static str {
276 match self {
277 Self::Completed => "COMPLETED",
278 Self::NotPlanned => "NOT_PLANNED",
279 }
280 }
281}
282
283#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
285#[serde(default, deny_unknown_fields)]
286pub struct GitHubProjectsConfig {
287 pub owner: String, pub project_number: u32, pub repository: Option<String>, #[serde(default = "default_token_env")]
300 pub token_env: String, #[serde(default = "default_endpoint")]
303 pub endpoint: String, #[serde(default)]
311 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, }
313
314#[derive(Debug, Clone, Copy, Default)]
316pub struct Plugin;
317
318impl SourcePlugin for Plugin {
319 fn kind(&self) -> &'static str {
320 KIND
321 }
322 fn config_schema(&self) -> Schema {
323 schema_for!(GitHubProjectsConfig)
324 }
325 fn build(
326 &self,
327 name: &SourceName,
328 config: &Value,
329 secrets: &dyn SecretResolver,
330 ) -> Result<Box<dyn TaskSource>, SourceError> {
331 let config: GitHubProjectsConfig =
332 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
333 message: format!("source {name}: {e}"),
334 })?;
335 let source =
336 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
337 SourceError::Config { message } => SourceError::Config {
338 message: format!("source {name}: {message}"),
339 },
340 SourceError::Auth { message } => SourceError::Auth {
341 message: format!("source {name}: {message}"),
342 },
343 other => other,
344 })?;
345 Ok(Box::new(source))
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
351enum StatusTarget {
352 Disabled,
354 Column(ColumnName),
356 Closed(ClosedState),
358}
359
360pub const CATEGORIES: [StatusCategory; 7] = [
370 StatusCategory::Draft,
371 StatusCategory::Backlog,
372 StatusCategory::Todo,
373 StatusCategory::InProgress,
374 StatusCategory::Done,
375 StatusCategory::Cancelled,
376 StatusCategory::Unknown,
377];
378
379#[must_use]
381pub const fn category_position(category: StatusCategory) -> usize {
382 match category {
383 StatusCategory::Draft => 0,
384 StatusCategory::Backlog => 1,
385 StatusCategory::Todo => 2,
386 StatusCategory::InProgress => 3,
387 StatusCategory::Done => 4,
388 StatusCategory::Cancelled => 5,
389 StatusCategory::Unknown => 6,
390 }
391}
392
393fn category_name(category: StatusCategory) -> &'static str {
395 match category {
396 StatusCategory::Draft => "draft",
397 StatusCategory::Backlog => "backlog",
398 StatusCategory::Todo => "todo",
399 StatusCategory::InProgress => "in-progress",
400 StatusCategory::Done => "done",
401 StatusCategory::Cancelled => "cancelled",
402 StatusCategory::Unknown => "unknown",
403 }
404}
405
406fn shipped_column(name: &'static str) -> ColumnName {
411 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
412}
413
414fn shipped_default(category: StatusCategory) -> StatusTarget {
416 match category {
417 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
418 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
419 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
420 StatusCategory::Done => StatusTarget::Closed(ClosedState::Completed),
421 StatusCategory::Cancelled => StatusTarget::Closed(ClosedState::NotPlanned),
422 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
423 }
424}
425
426#[derive(Debug, Clone)]
432struct StatusMapping {
433 targets: [StatusTarget; CATEGORIES.len()],
434}
435
436impl StatusMapping {
437 fn resolve(
438 configured: BTreeMap<String, Option<StatusTargetConfig>>,
439 instance: &SourceName,
440 ) -> Result<Self, SourceError> {
441 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
442 for (key, value) in configured {
443 let category = CATEGORIES
444 .iter()
445 .find(|category| category_name(**category) == key)
446 .ok_or_else(|| SourceError::Config {
447 message: format!(
448 "status_mapping names {key:?}, which is not a status category of source \
449 {instance}; the categories are {}",
450 CATEGORIES
451 .iter()
452 .map(|category| category_name(*category))
453 .collect::<Vec<_>>()
454 .join(", ")
455 ),
456 })?;
457 overrides.insert(category_name(*category), value);
458 }
459 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
462 None => shipped_default(category),
463 Some(None) => StatusTarget::Disabled,
464 Some(Some(StatusTargetConfig::Column(option))) => StatusTarget::Column(option),
465 Some(Some(StatusTargetConfig::Closed { closed })) => StatusTarget::Closed(closed),
466 });
467 let mapping = Self { targets };
468 for (index, category) in CATEGORIES.into_iter().enumerate() {
469 let StatusTarget::Column(option) = mapping.target(category) else {
470 continue;
471 };
472 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
473 matches!(mapping.target(**earlier), StatusTarget::Column(name)
474 if name.as_str().eq_ignore_ascii_case(option.as_str()))
475 }) {
476 return Err(SourceError::Config {
477 message: format!(
478 "status_mapping of source {instance} sends both {} and {} to the board \
479 option {:?}; one option cannot read back as two categories",
480 category_name(*other),
481 category_name(category),
482 option.as_str()
483 ),
484 });
485 }
486 }
487 Ok(mapping)
488 }
489
490 fn target(&self, category: StatusCategory) -> &StatusTarget {
491 &self.targets[category_position(category)]
492 }
493
494 fn category_of(&self, option: &str) -> Option<StatusCategory> {
496 CATEGORIES.into_iter().find(|category| {
497 matches!(self.target(*category), StatusTarget::Column(name)
498 if name.as_str().eq_ignore_ascii_case(option))
499 })
500 }
501}
502
503#[derive(Debug, Clone)]
505struct RepositoryTarget {
506 owner: String, name: String, }
509
510impl RepositoryTarget {
511 fn parse(value: &str) -> Result<Self, SourceError> {
512 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
513 message: format!(
514 "repository must be spelled owner/name; {value:?} names no repository"
515 ),
516 })?;
517 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
518 return Err(SourceError::Config {
519 message: format!(
520 "repository must be spelled owner/name with a GitHub login and one \
521 repository name; {value:?} is not"
522 ),
523 });
524 }
525 Ok(Self {
526 owner: owner.to_owned(),
527 name: name.to_owned(),
528 })
529 }
530
531 fn origin(&self) -> String {
532 format!("github.com/{}/{}", self.owner, self.name)
533 }
534}
535
536pub struct GitHubProjectsSource {
538 name: SourceName,
542 owner: String, project_number: u32, repository: Option<RepositoryTarget>,
545 endpoint: Url,
546 token: SecretString,
547 credential_name: String, statuses: StatusMapping,
549 client: Client,
550 created: Mutex<Vec<Resolved>>,
564}
565
566impl GitHubProjectsSource {
567 pub fn new(
574 name: &SourceName,
575 config: GitHubProjectsConfig,
576 secrets: &dyn SecretResolver,
577 ) -> Result<Self, SourceError> {
578 if !valid_github_owner(&config.owner) {
579 return Err(SourceError::Config {
580 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
581 });
582 }
583 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
584 return Err(SourceError::Config {
585 message: format!("project_number must be between 1 and {}", i32::MAX),
586 });
587 }
588 if !valid_environment_name(&config.token_env) {
589 return Err(SourceError::Config {
590 message: "token_env must be a valid environment-variable name".into(),
591 });
592 }
593 let repository = config
594 .repository
595 .as_deref()
596 .map(RepositoryTarget::parse)
597 .transpose()?;
598 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
599 message: format!("endpoint is not a valid URL: {e}"),
600 })?;
601 if endpoint.scheme() != "https"
602 && !(endpoint.scheme() == "http"
603 && endpoint
604 .host_str()
605 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
606 {
607 return Err(SourceError::Config {
608 message:
609 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
610 .into(),
611 });
612 }
613 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
614 message: format!("environment variable {} is missing or empty; set it to a fine-grained GitHub token granting Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board", config.token_env),
615 })?;
616 Ok(Self {
617 name: name.clone(),
618 owner: config.owner,
619 project_number: config.project_number,
620 repository,
621 endpoint,
622 token,
623 credential_name: config.token_env,
624 statuses: StatusMapping::resolve(config.status_mapping, name)?,
625 client: Client::builder()
626 .user_agent("onetaskgraph")
627 .build()
628 .map_err(|e| SourceError::Config {
629 message: format!("cannot build HTTP client: {e}"),
630 })?,
631 created: Mutex::new(Vec::new()),
632 })
633 }
634
635 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
636 let response = self
637 .client
638 .post(self.endpoint.clone())
639 .bearer_auth(self.token.expose_secret())
640 .json(&json!({"query": query, "variables": variables}))
641 .send()
642 .await
643 .map_err(|e| SourceError::Unavailable {
644 message: format!("GitHub GraphQL request failed: {e}"),
645 })?;
646 let status = response.status();
647 let retry_after = response
648 .headers()
649 .get("retry-after")
650 .and_then(|v| v.to_str().ok())
651 .and_then(|v| v.parse().ok());
652 let exhausted = response
653 .headers()
654 .get("x-ratelimit-remaining")
655 .and_then(|v| v.to_str().ok())
656 == Some("0");
657 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
658 return Err(SourceError::RateLimited {
659 retry_after_seconds: retry_after,
660 });
661 }
662 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
663 return Err(SourceError::Auth {
664 message: format!(
665 "GitHub rejected the configured credential with HTTP {status}; grant it Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board"
666 ),
667 });
668 }
669 if !status.is_success() {
670 return Err(SourceError::Unavailable {
671 message: format!("GitHub GraphQL returned HTTP {status}"),
672 });
673 }
674 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
675 message: format!("GitHub returned invalid JSON: {e}"),
676 })?;
677 let errors = body
678 .get("errors")
679 .map(|value| {
680 value.as_array().ok_or_else(|| SourceError::Malformed {
681 message: "GitHub response errors is not an array".into(),
682 })
683 })
684 .transpose()?;
685 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
686 let messages = errors
687 .iter()
688 .filter_map(|e| e.get("message").and_then(Value::as_str))
689 .collect::<Vec<_>>()
690 .join("; ");
691 let message = if messages.is_empty() {
692 "GitHub returned GraphQL errors".into()
693 } else {
694 messages
695 };
696 let normalized = message.to_ascii_lowercase();
697 if normalized.contains("resource not accessible") || normalized.contains("scope") {
698 return Err(SourceError::Auth {
699 message: format!(
700 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
701 self.credential_name
702 ),
703 });
704 }
705 return Err(SourceError::Refused { message });
706 }
707 body.get("data")
708 .filter(|data| data.is_object())
709 .cloned()
710 .ok_or_else(|| SourceError::Malformed {
711 message: "GitHub response has no data object".into(),
712 })
713 }
714
715 async fn board_page(
719 &self,
720 items_after: Option<&str>,
721 items_first: u32,
722 ) -> Result<Value, SourceError> {
723 let data = self
724 .graphql(
725 graphql::BOARD,
726 json!({"owner":self.owner,"number":self.project_number,
727 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
728 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
729 )
730 .await?;
731 data.pointer("/owner/projectV2")
732 .filter(|v| !v.is_null())
733 .cloned()
734 .ok_or_else(|| SourceError::Refused {
735 message: format!(
736 "GitHub project {}/{} was not found or is not visible to the token",
737 self.owner, self.project_number
738 ),
739 })
740 }
741
742 async fn board(&self) -> Result<Board, SourceError> {
744 let mut after: Option<String> = None;
745 let mut items = Vec::new();
746 let mut board;
747 loop {
748 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
749 for item in page
750 .pointer("/items/nodes")
751 .and_then(Value::as_array)
752 .ok_or_else(|| SourceError::Malformed {
753 message: "GitHub project items.nodes is not an array".into(),
754 })?
755 {
756 if let Some(resolved) = self.resolve(item)? {
757 items.push(resolved);
758 }
759 }
760 let info = page
761 .pointer("/items/pageInfo")
762 .ok_or_else(|| SourceError::Malformed {
763 message: "GitHub project items have no pageInfo".into(),
764 })?;
765 let has_next = required_bool(info, "hasNextPage")?;
766 let next = has_next
767 .then(|| required_str(info, "endCursor"))
768 .transpose()?;
769 board = page.clone();
770 match next {
771 Some(next) => {
772 validate_cursor_progress(after.as_deref(), next)?;
773 after = Some(next.to_owned());
774 }
775 None => break,
776 }
777 }
778 for own in self.created()?.iter() {
779 if !items.iter().any(|item| item.id == own.id) {
780 items.push(own.clone());
781 }
782 }
783 Ok(Board {
784 id: required_str(&board, "id")?.to_owned(),
785 fields: board.get("fields").cloned().unwrap_or(Value::Null),
786 items,
787 })
788 }
789
790 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
792 self.created.lock().map_err(|_| SourceError::Unavailable {
793 message: "this source's record of what it created in this run was left \
794 inconsistent by an earlier failure; next: run the command again"
795 .into(),
796 })
797 }
798
799 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
805 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
806 message: "GitHub project item is missing content".into(),
807 })?;
808 if content.is_null() {
809 return Ok(None);
810 }
811 let content_kind = match required_str(content, "__typename")? {
812 "Issue" => ContentKind::Issue,
813 "DraftIssue" => ContentKind::DraftIssue,
814 _ => return Ok(None),
815 };
816 let field_values = item
817 .get("fieldValues")
818 .ok_or_else(|| SourceError::Malformed {
819 message: "GitHub project item is missing fieldValues".into(),
820 })?;
821 complete_connection(field_values, "project item field values")?;
822 let nodes = field_values
823 .get("nodes")
824 .and_then(Value::as_array)
825 .ok_or_else(|| SourceError::Malformed {
826 message: "GitHub project item fieldValues.nodes is not an array".into(),
827 })?;
828 if let Some(labels) = content.get("labels") {
829 complete_connection(labels, "content labels")?;
830 }
831 for field_value in nodes {
832 if let Some(labels) = field_value.get("labels") {
833 complete_connection(labels, "project item field labels")?;
834 }
835 }
836 let (body, slot) = metadata_body(optional_str(content, "body")?.map(str::to_owned))?;
837 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
838 .map(|id| NativeId(id.to_owned()));
839 let sub_issues = match content_kind {
842 ContentKind::Issue => sub_issue_total(content)?,
843 ContentKind::DraftIssue => 0,
844 };
845 let content_id = required_str(content, "id")?;
846 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
847 message: format!("GitHub issue {content_id}: {message}"),
848 })?;
849 let kind = if parent.is_some() {
852 ItemKind::Task
853 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
854 ItemKind::Project
855 } else {
856 ItemKind::Task
857 };
858 let own_repository = content
859 .pointer("/repository/nameWithOwner")
860 .and_then(Value::as_str)
861 .map(|origin| Repository::try_from(format!("github.com/{origin}")))
862 .transpose()
863 .map_err(|message| SourceError::Malformed { message })?;
864 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
865 Repository::from_metadata(&slot)
866 .map_err(|message| SourceError::Malformed { message })?
867 } else {
868 own_repository.clone().into_iter().collect()
869 };
870 Ok(Some(Resolved {
871 item_id: required_str(item, "id")?.to_owned(),
872 id: NativeId(content_id.to_owned()),
873 content_kind,
874 kind,
875 title: required_str(content, "title")?.to_owned(),
876 body: body.filter(|value| !value.is_empty()),
877 status: self.status(item, content)?,
878 labels: labels(content, nodes)?,
879 parent,
880 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
881 url: optional_str(content, "url")?.map(str::to_owned),
882 created_at: optional_time(content, "createdAt")?,
883 updated_at: optional_time(content, "updatedAt")?,
884 own_repository,
885 repositories,
886 slot,
887 }))
888 }
889
890 fn status(&self, item: &Value, content: &Value) -> Result<Status, SourceError> {
900 let nodes = item
901 .pointer("/fieldValues/nodes")
902 .and_then(Value::as_array)
903 .expect("resolve validates fieldValues.nodes before mapping status");
904 let option = nodes
905 .iter()
906 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
907 .map(|value| required_str(value, "name"))
908 .transpose()?;
909 let state = optional_str(content, "state")?;
910 if state == Some("CLOSED") {
911 let category = match optional_str(content, "stateReason")? {
912 None | Some("COMPLETED") => StatusCategory::Done,
913 Some("NOT_PLANNED") => StatusCategory::Cancelled,
914 Some(_) => StatusCategory::Unknown,
915 };
916 let fallback = match category {
917 StatusCategory::Done => "Done",
918 StatusCategory::Cancelled => "Cancelled",
919 _ => "Closed",
920 };
921 return Ok(Status {
922 category,
923 name: option.unwrap_or(fallback).to_owned(),
924 });
925 }
926 let name = option.unwrap_or("Open").to_owned();
927 Ok(Status {
928 category: self
929 .statuses
930 .category_of(&name)
931 .unwrap_or(StatusCategory::Unknown),
932 name,
933 })
934 }
935
936 fn column_for(
944 &self,
945 board: &Board,
946 status: &Status,
947 target: &StatusTarget,
948 ) -> Result<Option<(String, String)>, SourceError> {
949 let (wanted, required) = match target {
950 StatusTarget::Column(wanted) => (wanted.as_str(), true),
951 StatusTarget::Closed(_) => (status.name.as_str(), false),
952 StatusTarget::Disabled => return Ok(None),
953 };
954 let missing = |detail: &str| SourceError::Refused {
955 message: format!(
956 "status {} of source {} needs the board Status option {wanted:?}, and {detail}; add that option to the board, or point status_mapping.{} of this source at one it has",
957 category_name(status.category),
958 self.name,
959 category_name(status.category)
960 ),
961 };
962 let Some(field) = Board::field(&board.fields, "Status")? else {
963 return if required {
964 Err(missing("this board has no Status field"))
965 } else {
966 Ok(None)
967 };
968 };
969 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
970 return if required {
971 Err(missing(
972 "this board's Status field is not a single-select field",
973 ))
974 } else {
975 Ok(None)
976 };
977 }
978 let option = field
979 .get("options")
980 .and_then(Value::as_array)
981 .and_then(|options| {
982 options.iter().find(|option| {
983 option
984 .get("name")
985 .and_then(Value::as_str)
986 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
987 })
988 });
989 match option {
990 None if required => Err(missing("this board does not have it")),
991 None => Ok(None),
992 Some(option) => Ok(Some((
993 required_str(field, "id")?.to_owned(),
994 required_str(option, "id")?.to_owned(),
995 ))),
996 }
997 }
998
999 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
1006 let target = self.statuses.target(category).clone();
1007 if target != StatusTarget::Disabled {
1008 return Ok(target);
1009 }
1010 Err(SourceError::Refused {
1011 message: if category == StatusCategory::Draft {
1012 format!(
1013 "status draft is disabled for source {}: draft is incompatible with this \
1014 integration because GitHub draft issues cannot have sub-issues, and this \
1015 source stores a project's tasks as its issue's sub-issues",
1016 self.name
1017 )
1018 } else {
1019 format!(
1020 "status {} is disabled for source {}; set status_mapping.{} of this source \
1021 to a board Status option name or to a closed state",
1022 category_name(category),
1023 self.name,
1024 category_name(category)
1025 )
1026 },
1027 })
1028 }
1029
1030 async fn set_item_field(
1031 &self,
1032 board_id: &str,
1033 item_id: &str,
1034 field_id: &str,
1035 value: Value,
1036 ) -> Result<(), SourceError> {
1037 let data = self
1038 .graphql(
1039 graphql::UPDATE_FIELD,
1040 json!({"input":{
1041 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
1042 }}),
1043 )
1044 .await?;
1045 let returned = data
1046 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
1047 .ok_or_else(|| SourceError::Malformed {
1048 message: "GitHub field update returned no project item".into(),
1049 })?;
1050 if required_str(returned, "id")? != item_id {
1051 return Err(SourceError::Malformed {
1052 message: "GitHub field update returned the wrong project item".into(),
1053 });
1054 }
1055 Ok(())
1056 }
1057
1058 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
1059 let mut after: Option<String> = None;
1060 let mut ids = Vec::new();
1061 loop {
1062 let data = self
1063 .graphql(
1064 graphql::ISSUE_DEPENDENCIES,
1065 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
1066 )
1067 .await?;
1068 let connection =
1069 data.pointer("/node/blockedBy")
1070 .ok_or_else(|| SourceError::Malformed {
1071 message: "GitHub dependency response has no blockedBy connection".into(),
1072 })?;
1073 ids.extend(
1074 connection
1075 .get("nodes")
1076 .and_then(Value::as_array)
1077 .ok_or_else(|| SourceError::Malformed {
1078 message: "GitHub dependency response nodes is not an array".into(),
1079 })?
1080 .iter()
1081 .map(|value| required_str(value, "id").map(str::to_owned))
1082 .collect::<Result<Vec<_>, _>>()?,
1083 );
1084 let next = next_cursor(connection)?;
1085 if let Some(next) = &next {
1086 validate_cursor_progress(after.as_deref(), &next.0)?;
1087 }
1088 after = next.map(|cursor| cursor.0);
1089 if after.is_none() {
1090 return Ok(ids);
1091 }
1092 }
1093 }
1094
1095 async fn dependencies(
1096 &self,
1097 id: &NativeId,
1098 near_kind: ItemKind,
1099 direction: Direction,
1100 page: &PageRequest,
1101 ) -> Result<Page<DependencyEdge>, SourceError> {
1102 validate_page(page)?;
1103 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
1104 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1105 let recorded = recorded_offset(cursor, direction)?;
1106 let data = self
1111 .graphql(
1112 graphql::ISSUE_DEPENDENCIES,
1113 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
1114 "after":if recorded.is_some() {None} else {cursor}}),
1115 )
1116 .await?;
1117 let node =
1118 data.get("node")
1119 .filter(|v| !v.is_null())
1120 .ok_or_else(|| SourceError::Refused {
1121 message: format!(
1122 "GitHub item {} was not found or does not support dependencies",
1123 id.0
1124 ),
1125 })?;
1126 let connection_name = match direction {
1127 Direction::DependsOn => "blockedBy",
1128 Direction::DependedOnBy => "blocking",
1129 };
1130 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
1134 if let Some(offset) = recorded {
1135 return Ok(recorded_page(
1136 self.recorded_edges(id, near_kind, direction, natively_names)
1137 .await?,
1138 offset,
1139 limit,
1140 ));
1141 }
1142 if natively_names.is_none() {
1143 return Ok(recorded_page(
1144 self.recorded_edges(id, near_kind, direction, natively_names)
1145 .await?,
1146 0,
1147 limit,
1148 ));
1149 }
1150 let connection = node
1151 .get(connection_name)
1152 .ok_or_else(|| SourceError::Malformed {
1153 message: "GitHub dependency response is missing its connection".into(),
1154 })?;
1155 let nodes = connection
1156 .get("nodes")
1157 .and_then(Value::as_array)
1158 .ok_or_else(|| SourceError::Malformed {
1159 message: "GitHub dependency response nodes is not an array".into(),
1160 })?;
1161 let items = nodes
1165 .iter()
1166 .map(|value| {
1167 let related = NativeId(required_str(value, "id")?.into());
1168 let related_kind = related_kind(value)?;
1169 let (from, to) = match direction {
1170 Direction::DependsOn => (
1171 DependencyEndpoint::from_native(id.clone(), near_kind),
1172 DependencyEndpoint::from_native(related, related_kind),
1173 ),
1174 Direction::DependedOnBy => (
1175 DependencyEndpoint::from_native(related, related_kind),
1176 DependencyEndpoint::from_native(id.clone(), near_kind),
1177 ),
1178 };
1179 Ok(DependencyEdge {
1180 from,
1181 to,
1182 kind: DependencyKind::Blocks,
1183 })
1184 })
1185 .collect::<Result<Vec<_>, SourceError>>()?;
1186 let mut next = next_cursor(connection)?;
1187 if let Some(next) = &next {
1188 validate_cursor_progress(cursor, &next.0)?;
1189 }
1190 if next.is_none()
1191 && !self
1192 .recorded_edges(id, near_kind, direction, natively_names)
1193 .await?
1194 .is_empty()
1195 {
1196 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1197 }
1198 Ok(Page { items, next })
1199 }
1200
1201 async fn recorded_edges(
1211 &self,
1212 id: &NativeId,
1213 near_kind: ItemKind,
1214 direction: Direction,
1215 natively_names: Option<ItemKind>,
1216 ) -> Result<Vec<DependencyEdge>, SourceError> {
1217 if direction != Direction::DependsOn {
1218 return Ok(Vec::new());
1219 }
1220 let Some(item) = self
1221 .board()
1222 .await?
1223 .items
1224 .into_iter()
1225 .find(|item| item.id == *id)
1226 else {
1227 return Ok(Vec::new());
1228 };
1229 DependencyEdge::recorded(&item.slot, id, near_kind, &self.name, natively_names)
1230 .map_err(|message| SourceError::Malformed { message })
1231 }
1232
1233 async fn repository_id(&self) -> Result<String, SourceError> {
1235 let repository = self
1236 .repository
1237 .as_ref()
1238 .ok_or_else(|| SourceError::Refused {
1239 message: format!(
1240 "source {} has no repository configured, and a GitHub Projects board has no \
1241 repository of its own to create an issue in; set repository: owner/name on \
1242 this source",
1243 self.name
1244 ),
1245 })?;
1246 let data = self
1247 .graphql(
1248 graphql::REPOSITORY,
1249 json!({"owner":repository.owner,"name":repository.name}),
1250 )
1251 .await?;
1252 let node = data
1253 .get("repository")
1254 .filter(|value| !value.is_null())
1255 .ok_or_else(|| SourceError::Refused {
1256 message: format!(
1257 "GitHub repository {}/{} was not found or is not visible to the token",
1258 repository.owner, repository.name
1259 ),
1260 })?;
1261 Ok(required_str(node, "id")?.to_owned())
1262 }
1263
1264 async fn write_item(
1266 &self,
1267 incoming: &Incoming<'_>,
1268 target: Option<&NativeId>,
1269 depends_on: &[DependencyEdge],
1270 ) -> Result<NativeId, SourceError> {
1271 let board = self.board().await?;
1272 let status_target = self.resolved_target(incoming.status.category)?;
1273 let column = self.column_for(&board, incoming.status, &status_target)?;
1274 let existing = target
1275 .map(|target| {
1276 board
1277 .items
1278 .iter()
1279 .find(|item| item.id == *target)
1280 .ok_or_else(|| SourceError::Refused {
1281 message: format!("GitHub destination item {} was not found", target.0),
1282 })
1283 })
1284 .transpose()?;
1285 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
1286 if content_kind == ContentKind::DraftIssue {
1287 if let StatusTarget::Closed(_) = status_target {
1288 return Err(SourceError::Refused {
1289 message: format!(
1290 "status {} of source {} closes the item's issue, and GitHub draft items \
1291 have no open or closed state",
1292 category_name(incoming.status.category),
1293 self.name
1294 ),
1295 });
1296 }
1297 if incoming.parent.is_some() {
1298 return Err(SourceError::Refused {
1299 message: "GitHub draft items cannot be a project's sub-issue".into(),
1300 });
1301 }
1302 }
1303 match existing {
1304 Some(item) if content_kind == ContentKind::Issue => {
1305 if item.labels != incoming.labels {
1306 return Err(SourceError::Refused {
1307 message: "GitHub issue labels differ from the labels being written".into(),
1308 });
1309 }
1310 }
1311 _ => {
1312 if !incoming.labels.is_empty() {
1313 return Err(SourceError::Refused {
1314 message: "GitHub items created by this destination carry no labels".into(),
1315 });
1316 }
1317 }
1318 }
1319
1320 let own_repository = match existing {
1321 Some(item) => item.own_repository.clone(),
1322 None => self
1323 .repository
1324 .as_ref()
1325 .map(|repository| Repository::try_from(repository.origin()))
1326 .transpose()
1327 .map_err(|message| SourceError::Config { message })?,
1328 };
1329 let (native, fallback) = self
1330 .partition_edges(&board, incoming.kind, content_kind, depends_on)
1331 .await?;
1332 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
1333 let body = compose_body(incoming.content, &slot)?;
1334 let origin = match incoming.metadata.get(ORIGIN_KEY) {
1341 None => "",
1342 Some(Value::String(origin)) => origin.as_str(),
1343 Some(other) => {
1344 return Err(SourceError::Refused {
1345 message: format!(
1346 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
1347 is {other}"
1348 ),
1349 });
1350 }
1351 };
1352 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
1356 Some(field) => {
1357 if required_str(field, "__typename")? != "ProjectV2Field" {
1358 return Err(SourceError::Refused {
1359 message: format!(
1360 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
1361 ),
1362 });
1363 }
1364 Some(required_str(field, "id")?.to_owned())
1365 }
1366 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
1367 return Err(SourceError::Refused {
1368 message: format!(
1369 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
1370 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
1371 the board"
1372 ),
1373 });
1374 }
1375 None => None,
1376 };
1377
1378 let (content_id, item_id) = match existing {
1379 Some(item) => {
1380 self.update_existing(item, incoming, &body, &status_target)
1381 .await?;
1382 (item.id.clone(), item.item_id.clone())
1383 }
1384 None => {
1385 self.create_and_file_issue(&board, incoming, &body, &status_target)
1386 .await?
1387 }
1388 };
1389
1390 let landed = self
1398 .finish_write(
1399 &board,
1400 incoming,
1401 &content_id,
1402 &item_id,
1403 content_kind,
1404 existing,
1405 origin_field.as_deref(),
1406 origin,
1407 column,
1408 &native,
1409 )
1410 .await;
1411 if let Err(error) = landed {
1412 if existing.is_none() {
1413 let _ = self.delete_issue(&content_id).await;
1416 }
1417 return Err(error);
1418 }
1419
1420 if existing.is_none() {
1421 let remembered = Resolved {
1424 item_id,
1425 id: content_id.clone(),
1426 content_kind,
1427 kind: incoming.kind,
1428 title: incoming.title.to_owned(),
1429 body: body.clone(),
1430 status: incoming.status.clone(),
1431 labels: incoming.labels.to_vec(),
1432 parent: incoming.parent.cloned(),
1433 origin: (!origin.is_empty()).then(|| origin.to_owned()),
1434 url: None,
1435 created_at: None,
1436 updated_at: None,
1437 own_repository,
1438 repositories: incoming.repositories.to_vec(),
1439 slot,
1440 };
1441 self.created()?.push(remembered);
1442 }
1443 Ok(content_id)
1444 }
1445
1446 #[allow(clippy::too_many_arguments)]
1457 async fn finish_write(
1458 &self,
1459 board: &Board,
1460 incoming: &Incoming<'_>,
1461 content_id: &NativeId,
1462 item_id: &str,
1463 content_kind: ContentKind,
1464 existing: Option<&Resolved>,
1465 origin_field: Option<&str>,
1466 origin: &str,
1467 column: Option<(String, String)>,
1468 native: &[String],
1469 ) -> Result<(), SourceError> {
1470 if let Some(field_id) = origin_field {
1471 self.set_item_field(&board.id, item_id, field_id, json!({"text":origin}))
1472 .await?;
1473 }
1474
1475 if let Some((field_id, option_id)) = column {
1476 self.set_item_field(
1477 &board.id,
1478 item_id,
1479 &field_id,
1480 json!({"singleSelectOptionId":option_id}),
1481 )
1482 .await?;
1483 }
1484
1485 if content_kind == ContentKind::Issue {
1486 self.reparent(
1487 existing.and_then(|item| item.parent.clone()),
1488 content_id,
1489 incoming.parent,
1490 )
1491 .await?;
1492 self.reconcile_blocked_by(content_id, native).await?;
1493 }
1494 Ok(())
1495 }
1496
1497 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
1499 let data = self
1500 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
1501 .await?;
1502 data.pointer("/deleteIssue/repository")
1503 .filter(|value| !value.is_null())
1504 .ok_or_else(|| SourceError::Malformed {
1505 message: "GitHub issue deletion returned no repository".into(),
1506 })?;
1507 self.created()?.retain(|own| own.id != *id);
1508 Ok(())
1509 }
1510
1511 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
1518 let board = self.board().await?;
1519 let Some(item) = board.items.iter().find(|item| item.id == *id) else {
1520 return Ok(());
1521 };
1522 if item.content_kind == ContentKind::DraftIssue {
1523 return Err(SourceError::Refused {
1524 message: format!(
1525 "GitHub item {} is a draft, and this source removes an item by deleting \
1526 its issue; next: remove it from the board by hand",
1527 id.0
1528 ),
1529 });
1530 }
1531 let data = self
1532 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
1533 .await?;
1534 data.pointer("/deleteIssue/repository")
1535 .filter(|value| !value.is_null())
1536 .ok_or_else(|| SourceError::Malformed {
1537 message: "GitHub issue deletion returned no repository".into(),
1538 })?;
1539 self.created()?.retain(|own| own.id != *id);
1540 Ok(())
1541 }
1542
1543 async fn partition_edges(
1545 &self,
1546 board: &Board,
1547 near_kind: ItemKind,
1548 near_content: ContentKind,
1549 depends_on: &[DependencyEdge],
1550 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
1551 let mut native = Vec::new();
1552 let mut fallback = Vec::new();
1553 for edge in depends_on {
1554 let same_source = edge
1555 .to
1556 .source()
1557 .is_none_or(|source| source == self.name.as_str());
1558 let far_id = if edge.to.is_qualified() {
1563 edge.to
1564 .id()
1565 .split_once(':')
1566 .map_or(edge.to.id(), |(_, native)| native)
1567 } else {
1568 edge.to.id()
1569 };
1570 let far = if same_source {
1571 Some(
1572 board
1573 .items
1574 .iter()
1575 .find(|item| item.id.0 == far_id)
1576 .ok_or_else(|| SourceError::Refused {
1577 message: format!("GitHub dependency item {far_id} was not found"),
1578 })?,
1579 )
1580 } else {
1581 None
1582 };
1583 if let Some(disagreeing) = far.filter(|far| far.kind != edge.to.kind) {
1589 return Err(SourceError::Refused {
1590 message: format!(
1591 "GitHub dependency item {far_id} is a {} of this board, and this item \
1592 names it as a {}; record the kind it is",
1593 disagreeing.kind.marker(),
1594 edge.to.kind.marker()
1595 ),
1596 });
1597 }
1598 let native_here = near_content == ContentKind::Issue
1602 && far.is_some_and(|far| {
1603 far.content_kind == ContentKind::Issue && edge.to.kind == near_kind
1604 });
1605 if native_here {
1606 native.push(far_id.to_owned());
1607 } else {
1608 fallback.push(edge.clone());
1609 }
1610 }
1611 Ok((native, fallback))
1612 }
1613
1614 async fn update_existing(
1615 &self,
1616 item: &Resolved,
1617 incoming: &Incoming<'_>,
1618 body: &Option<String>,
1619 status_target: &StatusTarget,
1620 ) -> Result<(), SourceError> {
1621 let (operation, input, pointer) = match item.content_kind {
1622 ContentKind::DraftIssue => (
1623 graphql::UPDATE_DRAFT,
1624 json!({"draftIssueId":item.id.0,"title":incoming.title,"body":body}),
1625 "/updateProjectV2DraftIssue/draftIssue",
1626 ),
1627 ContentKind::Issue => (
1628 graphql::UPDATE_ISSUE,
1629 json!({"id":item.id.0,"title":incoming.title,"body":body,
1630 "stateInput":state_input(status_target)}),
1631 "/updateIssue/issue",
1632 ),
1633 };
1634 let data = self.graphql(operation, json!({"input":input})).await?;
1635 let returned = data
1636 .pointer(pointer)
1637 .ok_or_else(|| SourceError::Malformed {
1638 message: "GitHub item update returned no item".into(),
1639 })?;
1640 if required_str(returned, "id")? != item.id.0 {
1641 return Err(SourceError::Malformed {
1642 message: "GitHub item update returned the wrong item".into(),
1643 });
1644 }
1645 Ok(())
1646 }
1647
1648 async fn create_and_file_issue(
1654 &self,
1655 board: &Board,
1656 incoming: &Incoming<'_>,
1657 body: &Option<String>,
1658 status_target: &StatusTarget,
1659 ) -> Result<(NativeId, String), SourceError> {
1660 let repository_id = self.repository_id().await?;
1661 let data = self
1662 .graphql(
1663 graphql::CREATE_ISSUE,
1664 json!({"input":{
1665 "repositoryId":repository_id,"title":incoming.title,"body":body
1666 }}),
1667 )
1668 .await?;
1669 let created = data
1670 .pointer("/createIssue/issue")
1671 .filter(|value| !value.is_null())
1672 .ok_or_else(|| SourceError::Malformed {
1673 message: "GitHub issue creation returned no issue".into(),
1674 })?;
1675 let content_id = NativeId(required_str(created, "id")?.to_owned());
1676 let added = match self
1680 .graphql(
1681 graphql::ADD_TO_BOARD,
1682 json!({"input":{"projectId":board.id,"contentId":content_id.0}}),
1683 )
1684 .await
1685 {
1686 Ok(added) => added,
1687 Err(error) => {
1688 let _ = self.delete_issue(&content_id).await;
1689 return Err(error);
1690 }
1691 };
1692 let item = added
1693 .pointer("/addProjectV2ItemById/item")
1694 .filter(|value| !value.is_null())
1695 .ok_or_else(|| SourceError::Malformed {
1696 message: "GitHub board addition returned no project item".into(),
1697 })?;
1698 if let StatusTarget::Closed(_) = status_target {
1699 let closed = self
1700 .graphql(
1701 graphql::UPDATE_ISSUE,
1702 json!({"input":{"id":content_id.0,"stateInput":state_input(status_target)}}),
1703 )
1704 .await?;
1705 let returned =
1706 closed
1707 .pointer("/updateIssue/issue")
1708 .ok_or_else(|| SourceError::Malformed {
1709 message: "GitHub item update returned no item".into(),
1710 })?;
1711 if required_str(returned, "id")? != content_id.0 {
1712 return Err(SourceError::Malformed {
1713 message: "GitHub item update returned the wrong item".into(),
1714 });
1715 }
1716 }
1717 Ok((content_id, required_str(item, "id")?.to_owned()))
1718 }
1719
1720 async fn reparent(
1722 &self,
1723 held: Option<NativeId>,
1724 child: &NativeId,
1725 wanted: Option<&NativeId>,
1726 ) -> Result<(), SourceError> {
1727 if held.as_ref() == wanted {
1728 return Ok(());
1729 }
1730 if let Some(held) = &held {
1731 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
1732 .await?;
1733 }
1734 if let Some(wanted) = wanted {
1735 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
1736 .await?;
1737 }
1738 Ok(())
1739 }
1740
1741 async fn sub_issue(
1742 &self,
1743 operation: &str,
1744 parent: &NativeId,
1745 child: &NativeId,
1746 root: &str,
1747 ) -> Result<(), SourceError> {
1748 let data = self
1749 .graphql(
1750 operation,
1751 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
1752 )
1753 .await?;
1754 let issue =
1755 data.pointer(&format!("/{root}/issue"))
1756 .ok_or_else(|| SourceError::Malformed {
1757 message: "GitHub sub-issue update returned no issue".into(),
1758 })?;
1759 let sub =
1760 data.pointer(&format!("/{root}/subIssue"))
1761 .ok_or_else(|| SourceError::Malformed {
1762 message: "GitHub sub-issue update returned no sub-issue".into(),
1763 })?;
1764 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
1765 return Err(SourceError::Malformed {
1766 message: "GitHub sub-issue update returned the wrong issues".into(),
1767 });
1768 }
1769 Ok(())
1770 }
1771
1772 async fn reconcile_blocked_by(
1773 &self,
1774 content_id: &NativeId,
1775 native: &[String],
1776 ) -> Result<(), SourceError> {
1777 let current = self.native_dependency_ids(content_id).await?;
1778 for (operation, far_id) in current
1779 .iter()
1780 .filter(|id| !native.contains(id))
1781 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1782 .chain(
1783 native
1784 .iter()
1785 .filter(|id| !current.contains(id))
1786 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1787 )
1788 {
1789 let data = self
1790 .graphql(
1791 operation,
1792 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1793 )
1794 .await?;
1795 let root = if operation == graphql::ADD_BLOCKED_BY {
1796 "addBlockedBy"
1797 } else {
1798 "removeBlockedBy"
1799 };
1800 let issue =
1801 data.pointer(&format!("/{root}/issue"))
1802 .ok_or_else(|| SourceError::Malformed {
1803 message: "GitHub dependency update returned no issue".into(),
1804 })?;
1805 let blocker = data
1806 .pointer(&format!("/{root}/blockingIssue"))
1807 .ok_or_else(|| SourceError::Malformed {
1808 message: "GitHub dependency update returned no blocking issue".into(),
1809 })?;
1810 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
1811 {
1812 return Err(SourceError::Malformed {
1813 message: "GitHub dependency update returned the wrong issues".into(),
1814 });
1815 }
1816 }
1817 Ok(())
1818 }
1819}
1820
1821struct Board {
1823 id: String,
1824 fields: Value,
1825 items: Vec<Resolved>,
1826}
1827
1828impl Board {
1829 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
1830 complete_connection(fields, "project fields")?;
1831 let nodes = fields
1832 .get("nodes")
1833 .and_then(Value::as_array)
1834 .ok_or_else(|| SourceError::Malformed {
1835 message: "GitHub project fields.nodes is not an array".into(),
1836 })?;
1837 Ok(nodes
1838 .iter()
1839 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
1840 }
1841}
1842
1843#[derive(Clone)]
1845struct Resolved {
1846 item_id: String,
1847 id: NativeId,
1848 content_kind: ContentKind,
1849 kind: ItemKind,
1850 title: String,
1851 body: Option<String>,
1852 status: Status,
1853 labels: Vec<Label>,
1854 parent: Option<NativeId>,
1855 origin: Option<String>,
1857 url: Option<String>,
1858 created_at: Option<DateTime<Utc>>,
1859 updated_at: Option<DateTime<Utc>>,
1860 own_repository: Option<Repository>,
1861 repositories: Vec<Repository>,
1862 slot: BTreeMap<String, Value>,
1863}
1864
1865impl Resolved {
1866 fn metadata(&self) -> BTreeMap<String, Value> {
1869 let mut metadata = self.slot.clone();
1870 metadata.remove(Repository::METADATA_KEY);
1871 metadata.remove(DependencyEdge::RECORDED_KEY);
1872 metadata.remove(ItemKind::METADATA_KEY);
1873 if let Some(origin) = &self.origin {
1874 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
1875 }
1876 metadata
1877 }
1878
1879 fn task(&self) -> Task {
1880 Task {
1881 id: self.id.clone(),
1882 title: self.title.clone(),
1883 content: self.body.clone(),
1884 status: self.status.clone(),
1885 labels: self.labels.clone(),
1886 project: self.parent.clone(),
1887 url: self.url.clone(),
1888 location: None,
1889 created_at: self.created_at,
1890 updated_at: self.updated_at,
1891 metadata: self.metadata(),
1892 repositories: self.repositories.clone(),
1893 }
1894 }
1895
1896 fn project(&self) -> Project {
1897 Project {
1898 id: self.id.clone(),
1899 title: self.title.clone(),
1900 content: self.body.clone(),
1901 status: self.status.clone(),
1902 labels: self.labels.clone(),
1903 url: self.url.clone(),
1904 location: None,
1905 created_at: self.created_at,
1906 updated_at: self.updated_at,
1907 metadata: self.metadata(),
1908 repositories: self.repositories.clone(),
1909 }
1910 }
1911}
1912
1913struct Incoming<'a> {
1915 kind: ItemKind,
1916 title: &'a str,
1917 content: Option<&'a str>,
1918 status: &'a Status,
1919 labels: &'a [Label],
1920 metadata: &'a BTreeMap<String, Value>,
1921 repositories: &'a [Repository],
1922 parent: Option<&'a NativeId>,
1923}
1924
1925#[derive(Clone, Copy, PartialEq, Eq)]
1926enum ContentKind {
1927 DraftIssue,
1928 Issue,
1929}
1930
1931fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
1937 let holds = |name: &String| {
1938 labels
1939 .iter()
1940 .any(|label| label.name.eq_ignore_ascii_case(name))
1941 };
1942 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
1943 && filter.all_of.iter().all(holds)
1944 && !filter.none_of.iter().any(holds)
1945}
1946
1947fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
1950 statuses.is_empty() || statuses.contains(&category)
1951}
1952
1953fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
1959 let terms = query.terms.to_lowercase();
1960 let in_title = title.to_lowercase().contains(&terms);
1961 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
1962 match query.fields {
1963 TextFields::Title => in_title,
1964 TextFields::Content => in_content,
1965 TextFields::TitleOrContent => in_title || in_content,
1966 }
1967}
1968
1969fn task_matches(task: &Task, query: &TaskQuery) -> bool {
1970 labels_match(&task.labels, &query.labels)
1971 && status_matches(task.status.category, &query.statuses)
1972 && match &query.project {
1973 ProjectFilter::Any => true,
1974 ProjectFilter::Orphans => task.project.is_none(),
1975 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
1976 }
1977 && query
1978 .text
1979 .as_ref()
1980 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
1981}
1982
1983fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
1984 labels_match(&project.labels, &query.labels)
1985 && status_matches(project.status.category, &query.statuses)
1986 && query
1987 .text
1988 .as_ref()
1989 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
1990}
1991
1992#[async_trait::async_trait]
1993impl TaskSource for GitHubProjectsSource {
1994 fn kind(&self) -> &'static str {
1995 KIND
1996 }
1997 fn capabilities(&self) -> Capabilities {
1998 Capabilities {
1999 projects: Support::Native,
2000 documents: Support::Unsupported,
2001 orphan_tasks: Support::Native,
2002 filter_by_label: Support::Native,
2003 filter_by_status: Support::Native,
2004 search_title: Support::Native,
2005 search_content: Support::Native,
2006 task_dependencies: DependencySupport::BothDirections,
2007 project_dependencies: DependencySupport::BothDirections,
2008 max_page_size: MAX_PAGE_SIZE,
2009 }
2010 }
2011 async fn health(&self) -> Result<Health, SourceError> {
2012 let board = self.board_page(None, 1).await?;
2013 Ok(Health {
2014 reachable: true,
2015 detail: Some(format!(
2016 "reading GitHub project {}/{} ({})",
2017 self.owner,
2018 self.project_number,
2019 required_str(&board, "title")?
2020 )),
2021 })
2022 }
2023 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
2024 Ok(self
2025 .board()
2026 .await?
2027 .items
2028 .iter()
2029 .find(|item| item.id == *id && item.kind == ItemKind::Task)
2030 .map(Resolved::task))
2031 }
2032 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
2033 Ok(self
2034 .board()
2035 .await?
2036 .items
2037 .iter()
2038 .find(|item| item.id == *id && item.kind == ItemKind::Project)
2039 .map(Resolved::project))
2040 }
2041 async fn query_tasks(
2042 &self,
2043 query: &TaskQuery,
2044 page: &PageRequest,
2045 ) -> Result<Page<Task>, SourceError> {
2046 validate_page(page)?;
2047 let tasks = self
2050 .board()
2051 .await?
2052 .items
2053 .iter()
2054 .filter(|item| item.kind == ItemKind::Task)
2055 .map(Resolved::task)
2056 .filter(|task| task_matches(task, query))
2057 .collect();
2058 Ok(offset_page(
2059 tasks,
2060 numeric_cursor(page.cursor.as_ref())?,
2061 page.limit.min(MAX_PAGE_SIZE) as usize,
2062 ))
2063 }
2064 async fn query_projects(
2065 &self,
2066 query: &ProjectQuery,
2067 page: &PageRequest,
2068 ) -> Result<Page<Project>, SourceError> {
2069 validate_page(page)?;
2070 let projects = self
2071 .board()
2072 .await?
2073 .items
2074 .iter()
2075 .filter(|item| item.kind == ItemKind::Project)
2076 .map(Resolved::project)
2077 .filter(|project| project_matches(project, query))
2078 .collect();
2079 Ok(offset_page(
2080 projects,
2081 numeric_cursor(page.cursor.as_ref())?,
2082 page.limit.min(MAX_PAGE_SIZE) as usize,
2083 ))
2084 }
2085 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
2086 validate_page(page)?;
2087 let offset = numeric_cursor(page.cursor.as_ref())?;
2088 let mut labels = self
2089 .board()
2090 .await?
2091 .items
2092 .into_iter()
2093 .flat_map(|item| item.labels)
2094 .fold(Vec::new(), |mut all, label| {
2095 if !all.iter().any(|x: &Label| x.id == label.id) {
2096 all.push(label);
2097 }
2098 all
2099 });
2100 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
2101 Ok(offset_page(
2102 labels,
2103 offset,
2104 page.limit.min(MAX_PAGE_SIZE) as usize,
2105 ))
2106 }
2107 async fn task_dependencies(
2108 &self,
2109 id: &NativeId,
2110 direction: Direction,
2111 page: &PageRequest,
2112 ) -> Result<Page<DependencyEdge>, SourceError> {
2113 self.dependencies(id, ItemKind::Task, direction, page).await
2114 }
2115 async fn project_dependencies(
2116 &self,
2117 id: &NativeId,
2118 direction: Direction,
2119 page: &PageRequest,
2120 ) -> Result<Page<DependencyEdge>, SourceError> {
2121 self.dependencies(id, ItemKind::Project, direction, page)
2122 .await
2123 }
2124
2125 fn writes(&self) -> WriteSupport {
2126 WriteSupport::Supported
2127 }
2128
2129 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
2130 self.write_item(
2131 &Incoming {
2132 kind: ItemKind::Task,
2133 title: &write.item.title,
2134 content: write.item.content.as_deref(),
2135 status: &write.item.status,
2136 labels: &write.item.labels,
2137 metadata: &write.item.metadata,
2138 repositories: &write.item.repositories,
2139 parent: write.item.project.as_ref(),
2140 },
2141 write.target.as_ref(),
2142 &write.depends_on,
2143 )
2144 .await
2145 }
2146
2147 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
2148 self.write_item(
2149 &Incoming {
2150 kind: ItemKind::Project,
2151 title: &write.item.title,
2152 content: write.item.content.as_deref(),
2153 status: &write.item.status,
2154 labels: &write.item.labels,
2155 metadata: &write.item.metadata,
2156 repositories: &write.item.repositories,
2157 parent: None,
2158 },
2159 write.target.as_ref(),
2160 &write.depends_on,
2161 )
2162 .await
2163 }
2164
2165 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
2166 self.delete_item(id).await
2167 }
2168
2169 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
2170 self.delete_item(id).await
2171 }
2172}
2173
2174const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
2177
2178const ORIGIN_FIELD: &str = "onetaskgraph.origin";
2183
2184const ORIGIN_KEY: &str = "onetaskgraph.origin";
2198
2199fn recorded_offset(
2207 cursor: Option<&str>,
2208 direction: Direction,
2209) -> Result<Option<usize>, SourceError> {
2210 cursor
2211 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
2212 .map(|offset| {
2213 if direction != Direction::DependsOn {
2214 return Err(SourceError::Config {
2215 message: format!(
2216 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
2217 reverse dependency read never issues; resume it in the direction \
2218 that reported it"
2219 ),
2220 });
2221 }
2222 offset.parse().map_err(|_| SourceError::Config {
2223 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
2224 })
2225 })
2226 .transpose()
2227}
2228
2229fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
2230 let mut page = offset_page(edges, offset, limit.max(1));
2231 page.next = page
2232 .next
2233 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
2234 page
2235}
2236
2237fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
2243 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
2244 if parent.is_some() {
2245 return Ok(ItemKind::Task);
2246 }
2247 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
2248 let id = required_str(value, "id")?;
2249 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
2250 message: format!("GitHub issue {id}: {message}"),
2251 })?;
2252 let sub_issues = sub_issue_total(value)?;
2253 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
2254 ItemKind::Project
2255 } else {
2256 ItemKind::Task
2257 })
2258}
2259
2260fn state_input(target: &StatusTarget) -> Value {
2267 match target {
2268 StatusTarget::Closed(reason) => json!({"value":"CLOSED","stateReason":reason.reason()}),
2269 StatusTarget::Column(_) | StatusTarget::Disabled => json!({"value":"OPEN"}),
2270 }
2271}
2272
2273fn slot_metadata(
2280 incoming: &Incoming<'_>,
2281 own_repository: Option<&Repository>,
2282 fallback: &[DependencyEdge],
2283) -> BTreeMap<String, Value> {
2284 let mut metadata = incoming.metadata.clone();
2285 metadata.remove(ORIGIN_KEY);
2286 metadata.insert(
2287 ItemKind::METADATA_KEY.to_owned(),
2288 Value::String(incoming.kind.marker().to_owned()),
2289 );
2290 let derivable = own_repository
2291 .map(|own| incoming.repositories == [own.clone()])
2292 .unwrap_or(incoming.repositories.is_empty());
2293 if derivable {
2294 metadata.remove(Repository::METADATA_KEY);
2295 } else {
2296 metadata.insert(
2297 Repository::METADATA_KEY.to_owned(),
2298 Value::Array(
2299 incoming
2300 .repositories
2301 .iter()
2302 .map(|repository| Value::String(repository.as_str().to_owned()))
2303 .collect(),
2304 ),
2305 );
2306 }
2307 if fallback.is_empty() {
2308 metadata.remove(DependencyEdge::RECORDED_KEY);
2309 } else {
2310 metadata.insert(
2311 DependencyEdge::RECORDED_KEY.to_owned(),
2312 Value::Array(
2313 fallback
2314 .iter()
2315 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
2316 .collect(),
2317 ),
2318 );
2319 }
2320 metadata
2321}
2322
2323fn labels(content: &Value, field_values: &[Value]) -> Result<Vec<Label>, SourceError> {
2324 let direct = optional_nodes(content.get("labels"), "content labels")?;
2325 let field = field_values
2326 .iter()
2327 .find_map(|value| value.get("labels"))
2328 .map(|labels| optional_nodes(Some(labels), "field labels"))
2329 .transpose()?
2330 .flatten();
2331 let labels = direct
2332 .into_iter()
2333 .flatten()
2334 .chain(field.into_iter().flatten())
2335 .map(|v| {
2336 Ok(Label {
2337 id: NativeId(required_str(v, "id")?.to_owned()),
2338 name: required_str(v, "name")?.to_owned(),
2339 color: optional_str(v, "color")?.map(str::to_owned),
2340 })
2341 })
2342 .collect::<Result<Vec<_>, SourceError>>()?
2343 .into_iter()
2344 .fold(Vec::new(), |mut labels, label| {
2345 if !labels.iter().any(|x: &Label| x.id == label.id) {
2346 labels.push(label);
2347 }
2348 labels
2349 });
2350 Ok(labels)
2351}
2352
2353fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
2354 let Some(node) = field_values
2355 .iter()
2356 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
2357 else {
2358 return Ok(None);
2359 };
2360 Ok(optional_str(node, "text")?.map(str::to_owned))
2361}
2362
2363fn valid_github_owner(owner: &str) -> bool {
2364 !owner.is_empty()
2365 && owner.len() <= 39
2366 && !owner.starts_with('-')
2367 && !owner.ends_with('-')
2368 && !owner.contains("--")
2369 && owner
2370 .bytes()
2371 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2372}
2373
2374fn valid_github_repository_name(name: &str) -> bool {
2377 !name.is_empty()
2378 && name.len() <= 100
2379 && name != "."
2380 && name != ".."
2381 && name
2382 .bytes()
2383 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2384}
2385
2386fn valid_environment_name(name: &str) -> bool {
2387 let mut bytes = name.bytes();
2388 bytes
2389 .next()
2390 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2391 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2392}
2393
2394fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
2401 let summary = issue
2402 .get("subIssuesSummary")
2403 .ok_or_else(|| SourceError::Malformed {
2404 message: "GitHub issue is missing subIssuesSummary".into(),
2405 })?;
2406 summary
2407 .get("total")
2408 .and_then(Value::as_u64)
2409 .ok_or_else(|| SourceError::Malformed {
2410 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
2411 })
2412}
2413
2414fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2415 value
2416 .get(field)
2417 .and_then(Value::as_str)
2418 .ok_or_else(|| SourceError::Malformed {
2419 message: format!("GitHub response is missing string field {field}"),
2420 })
2421}
2422
2423const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2431const METADATA_CLOSE: &str = "\n-->";
2432
2433fn metadata_body(
2439 body: Option<String>,
2440) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
2441 let Some(body) = body else {
2442 return Ok((None, BTreeMap::new()));
2443 };
2444 let Some(start) = body.rfind(METADATA_OPEN) else {
2445 return Ok((Some(body), BTreeMap::new()));
2446 };
2447 let encoded_start = start + METADATA_OPEN.len();
2448 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
2449 return Err(SourceError::Malformed {
2450 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
2451 });
2452 };
2453 let encoded_end = encoded_start + relative_end;
2454 if !body[encoded_end + METADATA_CLOSE.len()..].trim().is_empty() {
2455 return Ok((Some(body), BTreeMap::new()));
2456 }
2457 let metadata = serde_json::from_str(&body[encoded_start..encoded_end]).map_err(|error| {
2458 SourceError::Malformed {
2459 message: format!(
2460 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
2461 ),
2462 }
2463 })?;
2464 let visible = body[..start].trim_end();
2465 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2466}
2467
2468fn compose_body(
2469 content: Option<&str>,
2470 metadata: &BTreeMap<String, Value>,
2471) -> Result<Option<String>, SourceError> {
2472 let visible = content.unwrap_or_default();
2473 if metadata.is_empty() {
2474 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2475 }
2476 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
2477 message: error.to_string(),
2478 })?;
2479 Ok(Some(if visible.is_empty() {
2480 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2481 } else {
2482 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2483 }))
2484}
2485
2486fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
2487 value
2488 .get(field)
2489 .and_then(Value::as_bool)
2490 .ok_or_else(|| SourceError::Malformed {
2491 message: format!("GitHub response is missing boolean field {field}"),
2492 })
2493}
2494fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
2495 match value.get(field) {
2496 None | Some(Value::Null) => Ok(None),
2497 Some(value) => value
2498 .as_str()
2499 .map(Some)
2500 .ok_or_else(|| SourceError::Malformed {
2501 message: format!("GitHub response field {field} is not a string or null"),
2502 }),
2503 }
2504}
2505fn optional_nodes<'a>(
2506 connection: Option<&'a Value>,
2507 name: &str,
2508) -> Result<Option<&'a Vec<Value>>, SourceError> {
2509 match connection {
2510 None | Some(Value::Null) => Ok(None),
2511 Some(value) => value
2512 .get("nodes")
2513 .and_then(Value::as_array)
2514 .map(Some)
2515 .ok_or_else(|| SourceError::Malformed {
2516 message: format!("GitHub {name}.nodes is not an array"),
2517 }),
2518 }
2519}
2520fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
2521 let page_info = connection
2522 .get("pageInfo")
2523 .ok_or_else(|| SourceError::Malformed {
2524 message: format!("GitHub {name} has no pageInfo"),
2525 })?;
2526 if required_bool(page_info, "hasNextPage")? {
2527 return Err(SourceError::Malformed {
2528 message: format!(
2529 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
2530 ),
2531 });
2532 }
2533 Ok(())
2534}
2535fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2536 optional_str(value, field)?
2537 .map(|timestamp| {
2538 timestamp.parse().map_err(|error| SourceError::Malformed {
2539 message: format!("GitHub response field {field} is not a timestamp: {error}"),
2540 })
2541 })
2542 .transpose()
2543}
2544fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
2545 if page.limit == 0 {
2546 Err(SourceError::Config {
2547 message: "page limit must be at least 1".into(),
2548 })
2549 } else {
2550 Ok(())
2551 }
2552}
2553fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
2554 let page = connection
2555 .get("pageInfo")
2556 .filter(|value| value.is_object())
2557 .ok_or_else(|| SourceError::Malformed {
2558 message: "GitHub connection is missing pageInfo".into(),
2559 })?;
2560 if required_bool(page, "hasNextPage")? {
2561 let cursor = required_str(page, "endCursor")?;
2562 validate_cursor_progress(None, cursor)?;
2563 Ok(Some(Cursor(cursor.into())))
2564 } else {
2565 Ok(None)
2566 }
2567}
2568fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
2569 if next.is_empty() || previous == Some(next) {
2570 Err(SourceError::Malformed {
2571 message: "GitHub pagination cursor is empty or did not advance".into(),
2572 })
2573 } else {
2574 Ok(())
2575 }
2576}
2577fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
2578 cursor.map_or(Ok(0), |c| {
2579 c.0.parse().map_err(|_| SourceError::Config {
2580 message: "page cursor is invalid".into(),
2581 })
2582 })
2583}
2584fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
2585 if offset > items.len() {
2586 return Page::last(vec![]);
2587 }
2588 let tail = items.split_off(offset);
2589 let mut selected = tail;
2590 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
2591 selected.truncate(limit);
2592 Page {
2593 items: selected,
2594 next,
2595 }
2596}