1#![deny(missing_docs)]
139
140use std::collections::BTreeMap;
141use std::sync::Mutex;
142
143use chrono::{DateTime, Utc};
144use onetaskgraph_plugin_api::{
145 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
146 Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label, LabelFilter, Location,
147 NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, Repository, SecretResolver,
148 SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
149 TaskSource, TextFields, TextQuery, WriteSupport,
150};
151use reqwest::{Client, StatusCode, Url};
152use schemars::{Schema, schema_for};
153use secrecy::{ExposeSecret, SecretString};
154use serde::Deserialize;
155use serde_json::{Value, json};
156
157pub const KIND: &str = "github-projects";
159pub const MAX_PAGE_SIZE: u32 = 100;
161const NESTED_PAGE_SIZE: u32 = 50;
163
164pub const DESIGN_TITLE_PREFIX: &str = "DESIGN: ";
177
178pub mod graphql {
185 pub const BOARD: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
187 owner:repositoryOwner(login:$owner){
188 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
189 }
190 } fragment Board on ProjectV2 { id title
191 fields(first:$nestedFirst){nodes{
192 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
193 ... on ProjectV2Field{__typename id name}
194 }pageInfo{hasNextPage}}
195 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
196 ... on ProjectV2ItemFieldSingleSelectValue{name field{
197 ... on ProjectV2SingleSelectField{id name options{id name}}
198 }}
199 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
200 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
201 }pageInfo{hasNextPage}} content{
202 ... 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}}}
203 ... on PullRequest{__typename id}
204 ... on DraftIssue{__typename id title body createdAt updatedAt}
205 }} pageInfo{hasNextPage endCursor}}
206 }"#;
207 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
209 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
211 ... on Issue{
212 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
213 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
214 }}} fragment Related on Issue{id title body parent{id} subIssuesSummary{total}}"#;
215 pub const CREATE_ISSUE: &str =
217 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id url}}}"#;
218 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
220 pub const UPDATE_ISSUE: &str =
222 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
223 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
225 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
227 pub const ADD_SUB_ISSUE: &str =
229 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
230 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
232 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
234 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
236 pub const DELETE_ISSUE: &str =
242 r#"mutation($input:DeleteIssueInput!){deleteIssue(input:$input){repository{id}}}"#;
243}
244
245fn default_token_env() -> String {
246 "GH_PROJECTS_TOKEN".to_owned()
247}
248fn default_endpoint() -> String {
249 "https://api.github.com/graphql".to_owned()
250}
251
252#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
257#[serde(untagged)]
258pub enum StatusTargetConfig {
259 Column(ColumnName),
261 Closed {
263 closed: ClosedState,
265 },
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
273#[serde(try_from = "String")]
274pub struct ColumnName(String);
275
276impl ColumnName {
277 fn as_str(&self) -> &str {
279 &self.0
280 }
281}
282
283impl TryFrom<String> for ColumnName {
284 type Error = String;
285
286 fn try_from(name: String) -> Result<Self, Self::Error> {
287 if name.trim().is_empty() {
288 return Err("a status_mapping option name cannot be blank".to_owned());
289 }
290 Ok(Self(name))
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
299#[serde(rename_all = "kebab-case")]
300pub enum ClosedState {
301 Completed,
303 NotPlanned,
305}
306
307impl ClosedState {
308 const fn reason(self) -> &'static str {
309 match self {
310 Self::Completed => "COMPLETED",
311 Self::NotPlanned => "NOT_PLANNED",
312 }
313 }
314}
315
316#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
318#[serde(default, deny_unknown_fields)]
319pub struct GitHubProjectsConfig {
320 pub owner: String, pub project_number: u32, pub repository: Option<String>, #[serde(default = "default_token_env")]
333 pub token_env: String, #[serde(default = "default_endpoint")]
336 pub endpoint: String, #[serde(default)]
344 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, }
346
347#[derive(Debug, Clone, Copy, Default)]
349pub struct Plugin;
350
351impl SourcePlugin for Plugin {
352 fn kind(&self) -> &'static str {
353 KIND
354 }
355 fn config_schema(&self) -> Schema {
356 schema_for!(GitHubProjectsConfig)
357 }
358 fn build(
359 &self,
360 name: &SourceName,
361 config: &Value,
362 secrets: &dyn SecretResolver,
363 ) -> Result<Box<dyn TaskSource>, SourceError> {
364 let config: GitHubProjectsConfig =
365 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
366 message: format!("source {name}: {e}"),
367 })?;
368 let source =
369 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
370 SourceError::Config { message } => SourceError::Config {
371 message: format!("source {name}: {message}"),
372 },
373 SourceError::Auth { message } => SourceError::Auth {
374 message: format!("source {name}: {message}"),
375 },
376 other => other,
377 })?;
378 Ok(Box::new(source))
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
384enum StatusTarget {
385 Disabled,
387 Column(ColumnName),
389 Closed(ClosedState),
391}
392
393pub const CATEGORIES: [StatusCategory; 7] = [
403 StatusCategory::Draft,
404 StatusCategory::Backlog,
405 StatusCategory::Todo,
406 StatusCategory::InProgress,
407 StatusCategory::Done,
408 StatusCategory::Cancelled,
409 StatusCategory::Unknown,
410];
411
412#[must_use]
414pub const fn category_position(category: StatusCategory) -> usize {
415 match category {
416 StatusCategory::Draft => 0,
417 StatusCategory::Backlog => 1,
418 StatusCategory::Todo => 2,
419 StatusCategory::InProgress => 3,
420 StatusCategory::Done => 4,
421 StatusCategory::Cancelled => 5,
422 StatusCategory::Unknown => 6,
423 }
424}
425
426fn category_name(category: StatusCategory) -> &'static str {
428 match category {
429 StatusCategory::Draft => "draft",
430 StatusCategory::Backlog => "backlog",
431 StatusCategory::Todo => "todo",
432 StatusCategory::InProgress => "in-progress",
433 StatusCategory::Done => "done",
434 StatusCategory::Cancelled => "cancelled",
435 StatusCategory::Unknown => "unknown",
436 }
437}
438
439fn shipped_column(name: &'static str) -> ColumnName {
444 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
445}
446
447fn shipped_default(category: StatusCategory) -> StatusTarget {
449 match category {
450 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
451 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
452 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
453 StatusCategory::Done => StatusTarget::Closed(ClosedState::Completed),
454 StatusCategory::Cancelled => StatusTarget::Closed(ClosedState::NotPlanned),
455 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
456 }
457}
458
459#[derive(Debug, Clone)]
465struct StatusMapping {
466 targets: [StatusTarget; CATEGORIES.len()],
467}
468
469impl StatusMapping {
470 fn resolve(
471 configured: BTreeMap<String, Option<StatusTargetConfig>>,
472 instance: &SourceName,
473 ) -> Result<Self, SourceError> {
474 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
475 for (key, value) in configured {
476 let category = CATEGORIES
477 .iter()
478 .find(|category| category_name(**category) == key)
479 .ok_or_else(|| SourceError::Config {
480 message: format!(
481 "status_mapping names {key:?}, which is not a status category of source \
482 {instance}; the categories are {}",
483 CATEGORIES
484 .iter()
485 .map(|category| category_name(*category))
486 .collect::<Vec<_>>()
487 .join(", ")
488 ),
489 })?;
490 overrides.insert(category_name(*category), value);
491 }
492 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
495 None => shipped_default(category),
496 Some(None) => StatusTarget::Disabled,
497 Some(Some(StatusTargetConfig::Column(option))) => StatusTarget::Column(option),
498 Some(Some(StatusTargetConfig::Closed { closed })) => StatusTarget::Closed(closed),
499 });
500 let mapping = Self { targets };
501 for (index, category) in CATEGORIES.into_iter().enumerate() {
502 let StatusTarget::Column(option) = mapping.target(category) else {
503 continue;
504 };
505 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
506 matches!(mapping.target(**earlier), StatusTarget::Column(name)
507 if name.as_str().eq_ignore_ascii_case(option.as_str()))
508 }) {
509 return Err(SourceError::Config {
510 message: format!(
511 "status_mapping of source {instance} sends both {} and {} to the board \
512 option {:?}; one option cannot read back as two categories",
513 category_name(*other),
514 category_name(category),
515 option.as_str()
516 ),
517 });
518 }
519 }
520 Ok(mapping)
521 }
522
523 fn target(&self, category: StatusCategory) -> &StatusTarget {
524 &self.targets[category_position(category)]
525 }
526
527 fn category_of(&self, option: &str) -> Option<StatusCategory> {
529 CATEGORIES.into_iter().find(|category| {
530 matches!(self.target(*category), StatusTarget::Column(name)
531 if name.as_str().eq_ignore_ascii_case(option))
532 })
533 }
534}
535
536#[derive(Debug, Clone)]
538struct RepositoryTarget {
539 owner: String, name: String, }
542
543impl RepositoryTarget {
544 fn parse(value: &str) -> Result<Self, SourceError> {
545 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
546 message: format!(
547 "repository must be spelled owner/name; {value:?} names no repository"
548 ),
549 })?;
550 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
551 return Err(SourceError::Config {
552 message: format!(
553 "repository must be spelled owner/name with a GitHub login and one \
554 repository name; {value:?} is not"
555 ),
556 });
557 }
558 Ok(Self {
559 owner: owner.to_owned(),
560 name: name.to_owned(),
561 })
562 }
563
564 fn origin(&self) -> String {
565 format!("github.com/{}/{}", self.owner, self.name)
566 }
567}
568
569pub struct GitHubProjectsSource {
571 name: SourceName,
575 owner: String, project_number: u32, repository: Option<RepositoryTarget>,
578 endpoint: Url,
579 token: SecretString,
580 credential_name: String, statuses: StatusMapping,
582 client: Client,
583 created: Mutex<Vec<Resolved>>,
597}
598
599impl GitHubProjectsSource {
600 pub fn new(
607 name: &SourceName,
608 config: GitHubProjectsConfig,
609 secrets: &dyn SecretResolver,
610 ) -> Result<Self, SourceError> {
611 if !valid_github_owner(&config.owner) {
612 return Err(SourceError::Config {
613 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
614 });
615 }
616 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
617 return Err(SourceError::Config {
618 message: format!("project_number must be between 1 and {}", i32::MAX),
619 });
620 }
621 if !valid_environment_name(&config.token_env) {
622 return Err(SourceError::Config {
623 message: "token_env must be a valid environment-variable name".into(),
624 });
625 }
626 let repository = config
627 .repository
628 .as_deref()
629 .map(RepositoryTarget::parse)
630 .transpose()?;
631 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
632 message: format!("endpoint is not a valid URL: {e}"),
633 })?;
634 if endpoint.scheme() != "https"
635 && !(endpoint.scheme() == "http"
636 && endpoint
637 .host_str()
638 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
639 {
640 return Err(SourceError::Config {
641 message:
642 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
643 .into(),
644 });
645 }
646 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
647 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),
648 })?;
649 Ok(Self {
650 name: name.clone(),
651 owner: config.owner,
652 project_number: config.project_number,
653 repository,
654 endpoint,
655 token,
656 credential_name: config.token_env,
657 statuses: StatusMapping::resolve(config.status_mapping, name)?,
658 client: Client::builder()
659 .user_agent("onetaskgraph")
660 .build()
661 .map_err(|e| SourceError::Config {
662 message: format!("cannot build HTTP client: {e}"),
663 })?,
664 created: Mutex::new(Vec::new()),
665 })
666 }
667
668 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
669 let response = self
670 .client
671 .post(self.endpoint.clone())
672 .bearer_auth(self.token.expose_secret())
673 .json(&json!({"query": query, "variables": variables}))
674 .send()
675 .await
676 .map_err(|e| SourceError::Unavailable {
677 message: format!("GitHub GraphQL request failed: {e}"),
678 })?;
679 let status = response.status();
680 let retry_after = response
681 .headers()
682 .get("retry-after")
683 .and_then(|v| v.to_str().ok())
684 .and_then(|v| v.parse().ok());
685 let exhausted = response
686 .headers()
687 .get("x-ratelimit-remaining")
688 .and_then(|v| v.to_str().ok())
689 == Some("0");
690 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
691 return Err(SourceError::RateLimited {
692 retry_after_seconds: retry_after,
693 });
694 }
695 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
696 return Err(SourceError::Auth {
697 message: format!(
698 "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"
699 ),
700 });
701 }
702 if !status.is_success() {
703 return Err(SourceError::Unavailable {
704 message: format!("GitHub GraphQL returned HTTP {status}"),
705 });
706 }
707 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
708 message: format!("GitHub returned invalid JSON: {e}"),
709 })?;
710 let errors = body
711 .get("errors")
712 .map(|value| {
713 value.as_array().ok_or_else(|| SourceError::Malformed {
714 message: "GitHub response errors is not an array".into(),
715 })
716 })
717 .transpose()?;
718 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
719 let messages = errors
720 .iter()
721 .filter_map(|e| e.get("message").and_then(Value::as_str))
722 .collect::<Vec<_>>()
723 .join("; ");
724 let message = if messages.is_empty() {
725 "GitHub returned GraphQL errors".into()
726 } else {
727 messages
728 };
729 let normalized = message.to_ascii_lowercase();
730 if normalized.contains("resource not accessible") || normalized.contains("scope") {
731 return Err(SourceError::Auth {
732 message: format!(
733 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
734 self.credential_name
735 ),
736 });
737 }
738 return Err(SourceError::Refused { message });
739 }
740 body.get("data")
741 .filter(|data| data.is_object())
742 .cloned()
743 .ok_or_else(|| SourceError::Malformed {
744 message: "GitHub response has no data object".into(),
745 })
746 }
747
748 async fn board_page(
752 &self,
753 items_after: Option<&str>,
754 items_first: u32,
755 ) -> Result<Value, SourceError> {
756 let data = self
757 .graphql(
758 graphql::BOARD,
759 json!({"owner":self.owner,"number":self.project_number,
760 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
761 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
762 )
763 .await?;
764 data.pointer("/owner/projectV2")
765 .filter(|v| !v.is_null())
766 .cloned()
767 .ok_or_else(|| SourceError::Refused {
768 message: format!(
769 "GitHub project {}/{} was not found or is not visible to the token",
770 self.owner, self.project_number
771 ),
772 })
773 }
774
775 async fn board(&self) -> Result<Board, SourceError> {
777 let mut after: Option<String> = None;
778 let mut items = Vec::new();
779 let mut board;
780 loop {
781 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
782 for item in page
783 .pointer("/items/nodes")
784 .and_then(Value::as_array)
785 .ok_or_else(|| SourceError::Malformed {
786 message: "GitHub project items.nodes is not an array".into(),
787 })?
788 {
789 if let Some(resolved) = self.resolve(item)? {
790 items.push(resolved);
791 }
792 }
793 let info = page
794 .pointer("/items/pageInfo")
795 .ok_or_else(|| SourceError::Malformed {
796 message: "GitHub project items have no pageInfo".into(),
797 })?;
798 let has_next = required_bool(info, "hasNextPage")?;
799 let next = has_next
800 .then(|| required_str(info, "endCursor"))
801 .transpose()?;
802 board = page.clone();
803 match next {
804 Some(next) => {
805 validate_cursor_progress(after.as_deref(), next)?;
806 after = Some(next.to_owned());
807 }
808 None => break,
809 }
810 }
811 for own in self.created()?.iter() {
812 if !items.iter().any(|item| item.id == own.id) {
813 items.push(own.clone());
814 }
815 }
816 Ok(Board {
817 id: required_str(&board, "id")?.to_owned(),
818 fields: board.get("fields").cloned().unwrap_or(Value::Null),
819 items,
820 })
821 }
822
823 fn created(&self) -> Result<std::sync::MutexGuard<'_, Vec<Resolved>>, SourceError> {
825 self.created.lock().map_err(|_| SourceError::Unavailable {
826 message: "this source's record of what it created in this run was left \
827 inconsistent by an earlier failure; next: run the command again"
828 .into(),
829 })
830 }
831
832 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
838 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
839 message: "GitHub project item is missing content".into(),
840 })?;
841 if content.is_null() {
842 return Ok(None);
843 }
844 let content_kind = match required_str(content, "__typename")? {
845 "Issue" => ContentKind::Issue,
846 "DraftIssue" => ContentKind::DraftIssue,
847 _ => return Ok(None),
848 };
849 let field_values = item
850 .get("fieldValues")
851 .ok_or_else(|| SourceError::Malformed {
852 message: "GitHub project item is missing fieldValues".into(),
853 })?;
854 complete_connection(field_values, "project item field values")?;
855 let nodes = field_values
856 .get("nodes")
857 .and_then(Value::as_array)
858 .ok_or_else(|| SourceError::Malformed {
859 message: "GitHub project item fieldValues.nodes is not an array".into(),
860 })?;
861 if let Some(labels) = content.get("labels") {
862 complete_connection(labels, "content labels")?;
863 }
864 for field_value in nodes {
865 if let Some(labels) = field_value.get("labels") {
866 complete_connection(labels, "project item field labels")?;
867 }
868 }
869 let (body, slot) = metadata_body(optional_str(content, "body")?.map(str::to_owned))?;
870 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
871 .map(|id| NativeId(id.to_owned()));
872 let sub_issues = match content_kind {
875 ContentKind::Issue => sub_issue_total(content)?,
876 ContentKind::DraftIssue => 0,
877 };
878 let content_id = required_str(content, "id")?;
879 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
880 message: format!("GitHub issue {content_id}: {message}"),
881 })?;
882 let raw_title = required_str(content, "title")?;
883 let kind = if raw_title.starts_with(DESIGN_TITLE_PREFIX) {
888 BoardKind::Document
889 } else if parent.is_some() {
890 BoardKind::Work(ItemKind::Task)
894 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
895 BoardKind::Work(ItemKind::Project)
896 } else {
897 BoardKind::Work(ItemKind::Task)
898 };
899 let title = match kind {
902 BoardKind::Document => raw_title[DESIGN_TITLE_PREFIX.len()..].to_owned(),
903 BoardKind::Work(_) => raw_title.to_owned(),
904 };
905 let own_repository = content
906 .pointer("/repository/nameWithOwner")
907 .and_then(Value::as_str)
908 .map(|origin| Repository::try_from(format!("github.com/{origin}")))
909 .transpose()
910 .map_err(|message| SourceError::Malformed { message })?;
911 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
912 Repository::from_metadata(&slot)
913 .map_err(|message| SourceError::Malformed { message })?
914 } else {
915 own_repository.clone().into_iter().collect()
916 };
917 Ok(Some(Resolved {
918 item_id: required_str(item, "id")?.to_owned(),
919 id: NativeId(content_id.to_owned()),
920 content_kind,
921 kind,
922 title,
923 body: body.filter(|value| !value.is_empty()),
924 status: self.status(item, content)?,
925 labels: labels(content, nodes)?,
926 parent,
927 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
928 url: optional_str(content, "url")?.map(str::to_owned),
929 created_at: optional_time(content, "createdAt")?,
930 updated_at: optional_time(content, "updatedAt")?,
931 own_repository,
932 repositories,
933 slot,
934 }))
935 }
936
937 fn status(&self, item: &Value, content: &Value) -> Result<Status, SourceError> {
947 let nodes = item
948 .pointer("/fieldValues/nodes")
949 .and_then(Value::as_array)
950 .expect("resolve validates fieldValues.nodes before mapping status");
951 let option = nodes
952 .iter()
953 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
954 .map(|value| required_str(value, "name"))
955 .transpose()?;
956 let state = optional_str(content, "state")?;
957 if state == Some("CLOSED") {
958 let category = match optional_str(content, "stateReason")? {
959 None | Some("COMPLETED") => StatusCategory::Done,
960 Some("NOT_PLANNED") => StatusCategory::Cancelled,
961 Some(_) => StatusCategory::Unknown,
962 };
963 let fallback = match category {
964 StatusCategory::Done => "Done",
965 StatusCategory::Cancelled => "Cancelled",
966 _ => "Closed",
967 };
968 return Ok(Status {
969 category,
970 name: option.unwrap_or(fallback).to_owned(),
971 });
972 }
973 let name = option.unwrap_or("Open").to_owned();
974 Ok(Status {
975 category: self
976 .statuses
977 .category_of(&name)
978 .unwrap_or(StatusCategory::Unknown),
979 name,
980 })
981 }
982
983 fn column_for(
991 &self,
992 board: &Board,
993 status: &Status,
994 target: &StatusTarget,
995 ) -> Result<Option<(String, String)>, SourceError> {
996 let (wanted, required) = match target {
997 StatusTarget::Column(wanted) => (wanted.as_str(), true),
998 StatusTarget::Closed(_) => (status.name.as_str(), false),
999 StatusTarget::Disabled => return Ok(None),
1000 };
1001 let missing = |detail: &str| SourceError::Refused {
1002 message: format!(
1003 "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",
1004 category_name(status.category),
1005 self.name,
1006 category_name(status.category)
1007 ),
1008 };
1009 let Some(field) = Board::field(&board.fields, "Status")? else {
1010 return if required {
1011 Err(missing("this board has no Status field"))
1012 } else {
1013 Ok(None)
1014 };
1015 };
1016 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
1017 return if required {
1018 Err(missing(
1019 "this board's Status field is not a single-select field",
1020 ))
1021 } else {
1022 Ok(None)
1023 };
1024 }
1025 let option = field
1026 .get("options")
1027 .and_then(Value::as_array)
1028 .and_then(|options| {
1029 options.iter().find(|option| {
1030 option
1031 .get("name")
1032 .and_then(Value::as_str)
1033 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
1034 })
1035 });
1036 match option {
1037 None if required => Err(missing("this board does not have it")),
1038 None => Ok(None),
1039 Some(option) => Ok(Some((
1040 required_str(field, "id")?.to_owned(),
1041 required_str(option, "id")?.to_owned(),
1042 ))),
1043 }
1044 }
1045
1046 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
1053 let target = self.statuses.target(category).clone();
1054 if target != StatusTarget::Disabled {
1055 return Ok(target);
1056 }
1057 Err(SourceError::Refused {
1058 message: if category == StatusCategory::Draft {
1059 format!(
1060 "status draft is disabled for source {}: draft is incompatible with this \
1061 integration because GitHub draft issues cannot have sub-issues, and this \
1062 source stores a project's tasks as its issue's sub-issues",
1063 self.name
1064 )
1065 } else {
1066 format!(
1067 "status {} is disabled for source {}; set status_mapping.{} of this source \
1068 to a board Status option name or to a closed state",
1069 category_name(category),
1070 self.name,
1071 category_name(category)
1072 )
1073 },
1074 })
1075 }
1076
1077 async fn set_item_field(
1078 &self,
1079 board_id: &str,
1080 item_id: &str,
1081 field_id: &str,
1082 value: Value,
1083 ) -> Result<(), SourceError> {
1084 let data = self
1085 .graphql(
1086 graphql::UPDATE_FIELD,
1087 json!({"input":{
1088 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
1089 }}),
1090 )
1091 .await?;
1092 let returned = data
1093 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
1094 .ok_or_else(|| SourceError::Malformed {
1095 message: "GitHub field update returned no project item".into(),
1096 })?;
1097 if required_str(returned, "id")? != item_id {
1098 return Err(SourceError::Malformed {
1099 message: "GitHub field update returned the wrong project item".into(),
1100 });
1101 }
1102 Ok(())
1103 }
1104
1105 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
1106 let mut after: Option<String> = None;
1107 let mut ids = Vec::new();
1108 loop {
1109 let data = self
1110 .graphql(
1111 graphql::ISSUE_DEPENDENCIES,
1112 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
1113 )
1114 .await?;
1115 let connection =
1116 data.pointer("/node/blockedBy")
1117 .ok_or_else(|| SourceError::Malformed {
1118 message: "GitHub dependency response has no blockedBy connection".into(),
1119 })?;
1120 ids.extend(
1121 connection
1122 .get("nodes")
1123 .and_then(Value::as_array)
1124 .ok_or_else(|| SourceError::Malformed {
1125 message: "GitHub dependency response nodes is not an array".into(),
1126 })?
1127 .iter()
1128 .map(|value| required_str(value, "id").map(str::to_owned))
1129 .collect::<Result<Vec<_>, _>>()?,
1130 );
1131 let next = next_cursor(connection)?;
1132 if let Some(next) = &next {
1133 validate_cursor_progress(after.as_deref(), &next.0)?;
1134 }
1135 after = next.map(|cursor| cursor.0);
1136 if after.is_none() {
1137 return Ok(ids);
1138 }
1139 }
1140 }
1141
1142 async fn dependencies(
1143 &self,
1144 id: &NativeId,
1145 near_kind: ItemKind,
1146 direction: Direction,
1147 page: &PageRequest,
1148 ) -> Result<Page<DependencyEdge>, SourceError> {
1149 validate_page(page)?;
1150 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
1151 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1152 let recorded = recorded_offset(cursor, direction)?;
1153 let data = self
1158 .graphql(
1159 graphql::ISSUE_DEPENDENCIES,
1160 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
1161 "after":if recorded.is_some() {None} else {cursor}}),
1162 )
1163 .await?;
1164 let node =
1165 data.get("node")
1166 .filter(|v| !v.is_null())
1167 .ok_or_else(|| SourceError::Refused {
1168 message: format!(
1169 "GitHub item {} was not found or does not support dependencies",
1170 id.0
1171 ),
1172 })?;
1173 let connection_name = match direction {
1174 Direction::DependsOn => "blockedBy",
1175 Direction::DependedOnBy => "blocking",
1176 };
1177 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
1181 if let Some(offset) = recorded {
1182 return Ok(recorded_page(
1183 self.recorded_edges(id, near_kind, direction, natively_names)
1184 .await?,
1185 offset,
1186 limit,
1187 ));
1188 }
1189 if natively_names.is_none() {
1190 return Ok(recorded_page(
1191 self.recorded_edges(id, near_kind, direction, natively_names)
1192 .await?,
1193 0,
1194 limit,
1195 ));
1196 }
1197 let connection = node
1198 .get(connection_name)
1199 .ok_or_else(|| SourceError::Malformed {
1200 message: "GitHub dependency response is missing its connection".into(),
1201 })?;
1202 let nodes = connection
1203 .get("nodes")
1204 .and_then(Value::as_array)
1205 .ok_or_else(|| SourceError::Malformed {
1206 message: "GitHub dependency response nodes is not an array".into(),
1207 })?;
1208 let items = nodes
1212 .iter()
1213 .map(|value| {
1214 let related = NativeId(required_str(value, "id")?.into());
1215 let related_kind = related_kind(value)?;
1216 let (from, to) = match direction {
1217 Direction::DependsOn => (
1218 DependencyEndpoint::from_native(id.clone(), near_kind),
1219 DependencyEndpoint::from_native(related, related_kind),
1220 ),
1221 Direction::DependedOnBy => (
1222 DependencyEndpoint::from_native(related, related_kind),
1223 DependencyEndpoint::from_native(id.clone(), near_kind),
1224 ),
1225 };
1226 Ok(DependencyEdge {
1227 from,
1228 to,
1229 kind: DependencyKind::Blocks,
1230 })
1231 })
1232 .collect::<Result<Vec<_>, SourceError>>()?;
1233 let mut next = next_cursor(connection)?;
1234 if let Some(next) = &next {
1235 validate_cursor_progress(cursor, &next.0)?;
1236 }
1237 if next.is_none()
1238 && !self
1239 .recorded_edges(id, near_kind, direction, natively_names)
1240 .await?
1241 .is_empty()
1242 {
1243 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1244 }
1245 Ok(Page { items, next })
1246 }
1247
1248 async fn recorded_edges(
1258 &self,
1259 id: &NativeId,
1260 near_kind: ItemKind,
1261 direction: Direction,
1262 natively_names: Option<ItemKind>,
1263 ) -> Result<Vec<DependencyEdge>, SourceError> {
1264 if direction != Direction::DependsOn {
1265 return Ok(Vec::new());
1266 }
1267 let Some(item) = self
1268 .board()
1269 .await?
1270 .items
1271 .into_iter()
1272 .find(|item| item.id == *id)
1273 else {
1274 return Ok(Vec::new());
1275 };
1276 DependencyEdge::recorded(&item.slot, id, near_kind, &self.name, natively_names)
1277 .map_err(|message| SourceError::Malformed { message })
1278 }
1279
1280 async fn repository_id(&self) -> Result<String, SourceError> {
1282 let repository = self
1283 .repository
1284 .as_ref()
1285 .ok_or_else(|| SourceError::Refused {
1286 message: format!(
1287 "source {} has no repository configured, and a GitHub Projects board has no \
1288 repository of its own to create an issue in; set repository: owner/name on \
1289 this source",
1290 self.name
1291 ),
1292 })?;
1293 let data = self
1294 .graphql(
1295 graphql::REPOSITORY,
1296 json!({"owner":repository.owner,"name":repository.name}),
1297 )
1298 .await?;
1299 let node = data
1300 .get("repository")
1301 .filter(|value| !value.is_null())
1302 .ok_or_else(|| SourceError::Refused {
1303 message: format!(
1304 "GitHub repository {}/{} was not found or is not visible to the token",
1305 repository.owner, repository.name
1306 ),
1307 })?;
1308 Ok(required_str(node, "id")?.to_owned())
1309 }
1310
1311 async fn write_item(
1313 &self,
1314 incoming: &Incoming<'_>,
1315 target: Option<&NativeId>,
1316 depends_on: &[DependencyEdge],
1317 ) -> Result<NativeId, SourceError> {
1318 if let Written::Work(kind, _) = incoming.written
1323 && incoming.title.starts_with(DESIGN_TITLE_PREFIX)
1324 {
1325 return Err(SourceError::Refused {
1326 message: format!(
1327 "the title of this {} begins {DESIGN_TITLE_PREFIX:?}, which is how source {} \
1328 spells a document, so it would read back as one rather than as a {}; \
1329 retitle it, or copy it as a document",
1330 kind.marker(),
1331 self.name,
1332 kind.marker()
1333 ),
1334 });
1335 }
1336 let board = self.board().await?;
1337 let status_target = incoming
1338 .written
1339 .status()
1340 .map(|status| self.resolved_target(status.category))
1341 .transpose()?;
1342 let column = match (incoming.written.status(), status_target.as_ref()) {
1343 (Some(status), Some(target)) => self.column_for(&board, status, target)?,
1344 _ => None,
1345 };
1346 let existing = target
1347 .map(|target| {
1348 board
1349 .items
1350 .iter()
1351 .find(|item| item.id == *target)
1352 .ok_or_else(|| SourceError::Refused {
1353 message: format!("GitHub destination item {} was not found", target.0),
1354 })
1355 })
1356 .transpose()?;
1357 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
1358 if content_kind == ContentKind::DraftIssue {
1359 if let (Some(StatusTarget::Closed(_)), Some(status)) =
1360 (status_target.as_ref(), incoming.written.status())
1361 {
1362 return Err(SourceError::Refused {
1363 message: format!(
1364 "status {} of source {} closes the item's issue, and GitHub draft items \
1365 have no open or closed state",
1366 category_name(status.category),
1367 self.name
1368 ),
1369 });
1370 }
1371 if incoming.parent.is_some() {
1372 return Err(SourceError::Refused {
1373 message: "GitHub draft items cannot be a project's sub-issue".into(),
1374 });
1375 }
1376 }
1377 match existing {
1378 Some(item) if content_kind == ContentKind::Issue => {
1379 if item.labels != incoming.labels {
1380 return Err(SourceError::Refused {
1381 message: "GitHub issue labels differ from the labels being written".into(),
1382 });
1383 }
1384 }
1385 _ => {
1386 if !incoming.labels.is_empty() {
1387 return Err(SourceError::Refused {
1388 message: "GitHub items created by this destination carry no labels".into(),
1389 });
1390 }
1391 }
1392 }
1393
1394 let own_repository = match existing {
1395 Some(item) => item.own_repository.clone(),
1396 None => self
1397 .repository
1398 .as_ref()
1399 .map(|repository| Repository::try_from(repository.origin()))
1400 .transpose()
1401 .map_err(|message| SourceError::Config { message })?,
1402 };
1403 let (native, fallback) = self
1404 .partition_edges(&board, incoming.written.kind(), content_kind, depends_on)
1405 .await?;
1406 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
1407 let body = compose_body(incoming.content, &slot)?;
1408 let origin = match incoming.metadata.get(ORIGIN_KEY) {
1415 None => "",
1416 Some(Value::String(origin)) => origin.as_str(),
1417 Some(other) => {
1418 return Err(SourceError::Refused {
1419 message: format!(
1420 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
1421 is {other}"
1422 ),
1423 });
1424 }
1425 };
1426 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
1430 Some(field) => {
1431 if required_str(field, "__typename")? != "ProjectV2Field" {
1432 return Err(SourceError::Refused {
1433 message: format!(
1434 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
1435 ),
1436 });
1437 }
1438 Some(required_str(field, "id")?.to_owned())
1439 }
1440 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
1441 return Err(SourceError::Refused {
1442 message: format!(
1443 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
1444 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
1445 the board"
1446 ),
1447 });
1448 }
1449 None => None,
1450 };
1451
1452 let (content_id, item_id, url) = match existing {
1453 Some(item) => {
1454 self.update_existing(item, incoming, &body, status_target.as_ref())
1455 .await?;
1456 (item.id.clone(), item.item_id.clone(), item.url.clone())
1457 }
1458 None => {
1459 self.create_and_file_issue(&board, incoming, &body, status_target.as_ref())
1460 .await?
1461 }
1462 };
1463
1464 let landed = self
1472 .finish_write(
1473 &board,
1474 incoming,
1475 &content_id,
1476 &item_id,
1477 content_kind,
1478 existing,
1479 origin_field.as_deref(),
1480 origin,
1481 column,
1482 &native,
1483 )
1484 .await;
1485 if let Err(error) = landed {
1486 if existing.is_none() {
1487 let _ = self.delete_issue(&content_id).await;
1490 }
1491 return Err(error);
1492 }
1493
1494 if existing.is_none() {
1495 let remembered = Resolved {
1498 item_id,
1499 id: content_id.clone(),
1500 content_kind,
1501 kind: incoming.written.kind(),
1502 title: incoming.title.to_owned(),
1503 body: metadata_body(body.clone())?.0,
1508 status: incoming
1511 .written
1512 .status()
1513 .cloned()
1514 .unwrap_or_else(|| Status {
1515 category: StatusCategory::Unknown,
1516 name: "Open".to_owned(),
1517 }),
1518 labels: incoming.labels.to_vec(),
1519 parent: incoming.parent.cloned(),
1520 origin: (!origin.is_empty()).then(|| origin.to_owned()),
1521 url,
1522 created_at: None,
1523 updated_at: None,
1524 own_repository,
1525 repositories: incoming.repositories.to_vec(),
1526 slot,
1527 };
1528 self.created()?.push(remembered);
1529 }
1530 Ok(content_id)
1531 }
1532
1533 #[allow(clippy::too_many_arguments)]
1544 async fn finish_write(
1545 &self,
1546 board: &Board,
1547 incoming: &Incoming<'_>,
1548 content_id: &NativeId,
1549 item_id: &str,
1550 content_kind: ContentKind,
1551 existing: Option<&Resolved>,
1552 origin_field: Option<&str>,
1553 origin: &str,
1554 column: Option<(String, String)>,
1555 native: &[String],
1556 ) -> Result<(), SourceError> {
1557 if let Some(field_id) = origin_field {
1558 self.set_item_field(&board.id, item_id, field_id, json!({"text":origin}))
1559 .await?;
1560 }
1561
1562 if let Some((field_id, option_id)) = column {
1563 self.set_item_field(
1564 &board.id,
1565 item_id,
1566 &field_id,
1567 json!({"singleSelectOptionId":option_id}),
1568 )
1569 .await?;
1570 }
1571
1572 if content_kind == ContentKind::Issue {
1573 self.reparent(
1574 existing.and_then(|item| item.parent.clone()),
1575 content_id,
1576 incoming.parent,
1577 )
1578 .await?;
1579 if incoming.written.kind() != BoardKind::Document {
1585 self.reconcile_blocked_by(content_id, native).await?;
1586 }
1587 }
1588 Ok(())
1589 }
1590
1591 async fn delete_issue(&self, id: &NativeId) -> Result<(), SourceError> {
1593 let data = self
1594 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
1595 .await?;
1596 data.pointer("/deleteIssue/repository")
1597 .filter(|value| !value.is_null())
1598 .ok_or_else(|| SourceError::Malformed {
1599 message: "GitHub issue deletion returned no repository".into(),
1600 })?;
1601 self.created()?.retain(|own| own.id != *id);
1602 Ok(())
1603 }
1604
1605 async fn delete_item(&self, id: &NativeId) -> Result<(), SourceError> {
1612 let board = self.board().await?;
1613 let Some(item) = board.items.iter().find(|item| item.id == *id) else {
1614 return Ok(());
1615 };
1616 if item.content_kind == ContentKind::DraftIssue {
1617 return Err(SourceError::Refused {
1618 message: format!(
1619 "GitHub item {} is a draft, and this source removes an item by deleting \
1620 its issue; next: remove it from the board by hand",
1621 id.0
1622 ),
1623 });
1624 }
1625 let data = self
1626 .graphql(graphql::DELETE_ISSUE, json!({"input":{"issueId":id.0}}))
1627 .await?;
1628 data.pointer("/deleteIssue/repository")
1629 .filter(|value| !value.is_null())
1630 .ok_or_else(|| SourceError::Malformed {
1631 message: "GitHub issue deletion returned no repository".into(),
1632 })?;
1633 self.created()?.retain(|own| own.id != *id);
1634 Ok(())
1635 }
1636
1637 async fn partition_edges(
1639 &self,
1640 board: &Board,
1641 near_kind: BoardKind,
1642 near_content: ContentKind,
1643 depends_on: &[DependencyEdge],
1644 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
1645 let mut native = Vec::new();
1646 let mut fallback = Vec::new();
1647 for edge in depends_on {
1648 let same_source = edge
1649 .to
1650 .source()
1651 .is_none_or(|source| source == self.name.as_str());
1652 let far_id = if edge.to.is_qualified() {
1657 edge.to
1658 .id()
1659 .split_once(':')
1660 .map_or(edge.to.id(), |(_, native)| native)
1661 } else {
1662 edge.to.id()
1663 };
1664 let far = if same_source {
1665 Some(
1666 board
1667 .items
1668 .iter()
1669 .find(|item| item.id.0 == far_id)
1670 .ok_or_else(|| SourceError::Refused {
1671 message: format!("GitHub dependency item {far_id} was not found"),
1672 })?,
1673 )
1674 } else {
1675 None
1676 };
1677 if let Some(disagreeing) = far.filter(|far| far.kind != BoardKind::Work(edge.to.kind)) {
1688 return Err(SourceError::Refused {
1689 message: format!(
1690 "GitHub dependency item {far_id} is a {} of this board, and this item \
1691 names it as a {}; record the kind it is",
1692 disagreeing.kind.describes(),
1693 edge.to.kind.marker()
1694 ),
1695 });
1696 }
1697 let native_here = near_content == ContentKind::Issue
1701 && far.is_some_and(|far| {
1702 far.content_kind == ContentKind::Issue
1703 && BoardKind::Work(edge.to.kind) == near_kind
1704 });
1705 if native_here {
1706 native.push(far_id.to_owned());
1707 } else {
1708 fallback.push(edge.clone());
1709 }
1710 }
1711 Ok((native, fallback))
1712 }
1713
1714 async fn update_existing(
1715 &self,
1716 item: &Resolved,
1717 incoming: &Incoming<'_>,
1718 body: &Option<String>,
1719 status_target: Option<&StatusTarget>,
1720 ) -> Result<(), SourceError> {
1721 let title = incoming.written_title();
1722 let (operation, input, pointer) = match item.content_kind {
1723 ContentKind::DraftIssue => (
1724 graphql::UPDATE_DRAFT,
1725 json!({"draftIssueId":item.id.0,"title":title,"body":body}),
1726 "/updateProjectV2DraftIssue/draftIssue",
1727 ),
1728 ContentKind::Issue => (
1729 graphql::UPDATE_ISSUE,
1730 json!({"id":item.id.0,"title":title,"body":body,
1731 "stateInput":state_input(status_target)}),
1732 "/updateIssue/issue",
1733 ),
1734 };
1735 let data = self.graphql(operation, json!({"input":input})).await?;
1736 let returned = data
1737 .pointer(pointer)
1738 .ok_or_else(|| SourceError::Malformed {
1739 message: "GitHub item update returned no item".into(),
1740 })?;
1741 if required_str(returned, "id")? != item.id.0 {
1742 return Err(SourceError::Malformed {
1743 message: "GitHub item update returned the wrong item".into(),
1744 });
1745 }
1746 Ok(())
1747 }
1748
1749 async fn create_and_file_issue(
1762 &self,
1763 board: &Board,
1764 incoming: &Incoming<'_>,
1765 body: &Option<String>,
1766 status_target: Option<&StatusTarget>,
1767 ) -> Result<(NativeId, String, Option<String>), SourceError> {
1768 let repository_id = self.repository_id().await?;
1769 let data = self
1770 .graphql(
1771 graphql::CREATE_ISSUE,
1772 json!({"input":{
1773 "repositoryId":repository_id,"title":incoming.written_title(),"body":body
1774 }}),
1775 )
1776 .await?;
1777 let created = data
1778 .pointer("/createIssue/issue")
1779 .filter(|value| !value.is_null())
1780 .ok_or_else(|| SourceError::Malformed {
1781 message: "GitHub issue creation returned no issue".into(),
1782 })?;
1783 let content_id = NativeId(required_str(created, "id")?.to_owned());
1784 let url = optional_str(created, "url")?.map(str::to_owned);
1788 let added = match self
1792 .graphql(
1793 graphql::ADD_TO_BOARD,
1794 json!({"input":{"projectId":board.id,"contentId":content_id.0}}),
1795 )
1796 .await
1797 {
1798 Ok(added) => added,
1799 Err(error) => {
1800 let _ = self.delete_issue(&content_id).await;
1801 return Err(error);
1802 }
1803 };
1804 let item = added
1805 .pointer("/addProjectV2ItemById/item")
1806 .filter(|value| !value.is_null())
1807 .ok_or_else(|| SourceError::Malformed {
1808 message: "GitHub board addition returned no project item".into(),
1809 })?;
1810 if let Some(StatusTarget::Closed(_)) = status_target {
1811 let closed = self
1812 .graphql(
1813 graphql::UPDATE_ISSUE,
1814 json!({"input":{"id":content_id.0,"stateInput":state_input(status_target)}}),
1815 )
1816 .await?;
1817 let returned =
1818 closed
1819 .pointer("/updateIssue/issue")
1820 .ok_or_else(|| SourceError::Malformed {
1821 message: "GitHub item update returned no item".into(),
1822 })?;
1823 if required_str(returned, "id")? != content_id.0 {
1824 return Err(SourceError::Malformed {
1825 message: "GitHub item update returned the wrong item".into(),
1826 });
1827 }
1828 }
1829 Ok((content_id, required_str(item, "id")?.to_owned(), url))
1830 }
1831
1832 async fn reparent(
1834 &self,
1835 held: Option<NativeId>,
1836 child: &NativeId,
1837 wanted: Option<&NativeId>,
1838 ) -> Result<(), SourceError> {
1839 if held.as_ref() == wanted {
1840 return Ok(());
1841 }
1842 if let Some(held) = &held {
1843 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
1844 .await?;
1845 }
1846 if let Some(wanted) = wanted {
1847 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
1848 .await?;
1849 }
1850 Ok(())
1851 }
1852
1853 async fn sub_issue(
1854 &self,
1855 operation: &str,
1856 parent: &NativeId,
1857 child: &NativeId,
1858 root: &str,
1859 ) -> Result<(), SourceError> {
1860 let data = self
1861 .graphql(
1862 operation,
1863 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
1864 )
1865 .await?;
1866 let issue =
1867 data.pointer(&format!("/{root}/issue"))
1868 .ok_or_else(|| SourceError::Malformed {
1869 message: "GitHub sub-issue update returned no issue".into(),
1870 })?;
1871 let sub =
1872 data.pointer(&format!("/{root}/subIssue"))
1873 .ok_or_else(|| SourceError::Malformed {
1874 message: "GitHub sub-issue update returned no sub-issue".into(),
1875 })?;
1876 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
1877 return Err(SourceError::Malformed {
1878 message: "GitHub sub-issue update returned the wrong issues".into(),
1879 });
1880 }
1881 Ok(())
1882 }
1883
1884 async fn reconcile_blocked_by(
1885 &self,
1886 content_id: &NativeId,
1887 native: &[String],
1888 ) -> Result<(), SourceError> {
1889 let current = self.native_dependency_ids(content_id).await?;
1890 for (operation, far_id) in current
1891 .iter()
1892 .filter(|id| !native.contains(id))
1893 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1894 .chain(
1895 native
1896 .iter()
1897 .filter(|id| !current.contains(id))
1898 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1899 )
1900 {
1901 let data = self
1902 .graphql(
1903 operation,
1904 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1905 )
1906 .await?;
1907 let root = if operation == graphql::ADD_BLOCKED_BY {
1908 "addBlockedBy"
1909 } else {
1910 "removeBlockedBy"
1911 };
1912 let issue =
1913 data.pointer(&format!("/{root}/issue"))
1914 .ok_or_else(|| SourceError::Malformed {
1915 message: "GitHub dependency update returned no issue".into(),
1916 })?;
1917 let blocker = data
1918 .pointer(&format!("/{root}/blockingIssue"))
1919 .ok_or_else(|| SourceError::Malformed {
1920 message: "GitHub dependency update returned no blocking issue".into(),
1921 })?;
1922 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
1923 {
1924 return Err(SourceError::Malformed {
1925 message: "GitHub dependency update returned the wrong issues".into(),
1926 });
1927 }
1928 }
1929 Ok(())
1930 }
1931}
1932
1933struct Board {
1935 id: String,
1936 fields: Value,
1937 items: Vec<Resolved>,
1938}
1939
1940impl Board {
1941 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
1942 complete_connection(fields, "project fields")?;
1943 let nodes = fields
1944 .get("nodes")
1945 .and_then(Value::as_array)
1946 .ok_or_else(|| SourceError::Malformed {
1947 message: "GitHub project fields.nodes is not an array".into(),
1948 })?;
1949 Ok(nodes
1950 .iter()
1951 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
1952 }
1953}
1954
1955#[derive(Clone)]
1957struct Resolved {
1958 item_id: String,
1959 id: NativeId,
1960 content_kind: ContentKind,
1961 kind: BoardKind,
1962 title: String,
1963 body: Option<String>,
1964 status: Status,
1965 labels: Vec<Label>,
1966 parent: Option<NativeId>,
1967 origin: Option<String>,
1969 url: Option<String>,
1970 created_at: Option<DateTime<Utc>>,
1971 updated_at: Option<DateTime<Utc>>,
1972 own_repository: Option<Repository>,
1973 repositories: Vec<Repository>,
1974 slot: BTreeMap<String, Value>,
1975}
1976
1977impl Resolved {
1978 fn metadata(&self) -> BTreeMap<String, Value> {
1981 let mut metadata = self.slot.clone();
1982 metadata.remove(Repository::METADATA_KEY);
1983 metadata.remove(DependencyEdge::RECORDED_KEY);
1984 metadata.remove(ItemKind::METADATA_KEY);
1985 if let Some(origin) = &self.origin {
1986 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
1987 }
1988 metadata
1989 }
1990
1991 fn location(&self) -> Option<Location> {
2005 self.url.clone().map(Location::Url)
2006 }
2007
2008 fn task(&self) -> Task {
2009 Task {
2010 id: self.id.clone(),
2011 title: self.title.clone(),
2012 content: self.body.clone(),
2013 status: self.status.clone(),
2014 labels: self.labels.clone(),
2015 project: self.parent.clone(),
2016 url: self.url.clone(),
2017 location: self.location(),
2018 created_at: self.created_at,
2019 updated_at: self.updated_at,
2020 metadata: self.metadata(),
2021 repositories: self.repositories.clone(),
2022 }
2023 }
2024
2025 fn project(&self) -> Project {
2026 Project {
2027 id: self.id.clone(),
2028 title: self.title.clone(),
2029 content: self.body.clone(),
2030 status: self.status.clone(),
2031 labels: self.labels.clone(),
2032 url: self.url.clone(),
2033 location: self.location(),
2034 created_at: self.created_at,
2035 updated_at: self.updated_at,
2036 metadata: self.metadata(),
2037 repositories: self.repositories.clone(),
2038 }
2039 }
2040
2041 fn document(&self) -> Document {
2044 Document {
2045 id: self.id.clone(),
2046 title: self.title.clone(),
2047 content: self.body.clone(),
2048 project: self.parent.clone(),
2049 labels: self.labels.clone(),
2050 url: self.url.clone(),
2051 location: self.location(),
2052 created_at: self.created_at,
2053 updated_at: self.updated_at,
2054 metadata: self.metadata(),
2055 repositories: self.repositories.clone(),
2056 }
2057 }
2058}
2059
2060enum Written<'a> {
2067 Document,
2069 Work(ItemKind, &'a Status),
2071}
2072
2073impl Written<'_> {
2074 const fn kind(&self) -> BoardKind {
2076 match self {
2077 Self::Document => BoardKind::Document,
2078 Self::Work(kind, _) => BoardKind::Work(*kind),
2079 }
2080 }
2081
2082 const fn status(&self) -> Option<&Status> {
2086 match self {
2087 Self::Document => None,
2088 Self::Work(_, status) => Some(status),
2089 }
2090 }
2091}
2092
2093struct Incoming<'a> {
2095 written: Written<'a>,
2096 title: &'a str,
2099 content: Option<&'a str>,
2100 labels: &'a [Label],
2101 metadata: &'a BTreeMap<String, Value>,
2102 repositories: &'a [Repository],
2103 parent: Option<&'a NativeId>,
2104}
2105
2106impl Incoming<'_> {
2107 fn written_title(&self) -> String {
2109 match self.written {
2110 Written::Document => format!("{DESIGN_TITLE_PREFIX}{}", self.title),
2111 Written::Work(..) => self.title.to_owned(),
2112 }
2113 }
2114}
2115
2116#[derive(Clone, Copy, PartialEq, Eq)]
2117enum ContentKind {
2118 DraftIssue,
2119 Issue,
2120}
2121
2122#[derive(Clone, Copy, PartialEq, Eq)]
2131enum BoardKind {
2132 Document,
2134 Work(ItemKind),
2136}
2137
2138impl BoardKind {
2139 const fn describes(self) -> &'static str {
2141 match self {
2142 Self::Document => "document",
2143 Self::Work(kind) => kind.marker(),
2144 }
2145 }
2146}
2147
2148fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
2154 let holds = |name: &String| {
2155 labels
2156 .iter()
2157 .any(|label| label.name.eq_ignore_ascii_case(name))
2158 };
2159 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
2160 && filter.all_of.iter().all(holds)
2161 && !filter.none_of.iter().any(holds)
2162}
2163
2164fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
2167 statuses.is_empty() || statuses.contains(&category)
2168}
2169
2170fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
2176 let terms = query.terms.to_lowercase();
2177 let in_title = title.to_lowercase().contains(&terms);
2178 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
2179 match query.fields {
2180 TextFields::Title => in_title,
2181 TextFields::Content => in_content,
2182 TextFields::TitleOrContent => in_title || in_content,
2183 }
2184}
2185
2186fn task_matches(task: &Task, query: &TaskQuery) -> bool {
2187 labels_match(&task.labels, &query.labels)
2188 && status_matches(task.status.category, &query.statuses)
2189 && match &query.project {
2190 ProjectFilter::Any => true,
2191 ProjectFilter::Orphans => task.project.is_none(),
2192 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
2193 }
2194 && query
2195 .text
2196 .as_ref()
2197 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
2198}
2199
2200fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
2201 labels_match(&project.labels, &query.labels)
2202 && status_matches(project.status.category, &query.statuses)
2203 && query
2204 .text
2205 .as_ref()
2206 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
2207}
2208
2209fn document_matches(document: &Document, query: &DocumentQuery) -> bool {
2216 labels_match(&document.labels, &query.labels)
2217 && match &query.project {
2218 ProjectFilter::Any => true,
2219 ProjectFilter::Orphans => document.project.is_none(),
2220 ProjectFilter::Is(id) => document.project.as_ref() == Some(id),
2221 }
2222 && query
2223 .text
2224 .as_ref()
2225 .is_none_or(|text| text_matches(&document.title, document.content.as_deref(), text))
2226}
2227
2228#[async_trait::async_trait]
2229impl TaskSource for GitHubProjectsSource {
2230 fn kind(&self) -> &'static str {
2231 KIND
2232 }
2233 fn capabilities(&self) -> Capabilities {
2234 Capabilities {
2235 projects: Support::Native,
2236 documents: Support::Native,
2237 orphan_tasks: Support::Native,
2238 filter_by_label: Support::Native,
2239 filter_by_status: Support::Native,
2240 search_title: Support::Native,
2241 search_content: Support::Native,
2242 task_dependencies: DependencySupport::BothDirections,
2243 project_dependencies: DependencySupport::BothDirections,
2244 max_page_size: MAX_PAGE_SIZE,
2245 }
2246 }
2247 async fn health(&self) -> Result<Health, SourceError> {
2248 let board = self.board_page(None, 1).await?;
2249 Ok(Health {
2250 reachable: true,
2251 detail: Some(format!(
2252 "reading GitHub project {}/{} ({})",
2253 self.owner,
2254 self.project_number,
2255 required_str(&board, "title")?
2256 )),
2257 })
2258 }
2259 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
2260 Ok(self
2261 .board()
2262 .await?
2263 .items
2264 .iter()
2265 .find(|item| item.id == *id && item.kind == BoardKind::Work(ItemKind::Task))
2266 .map(Resolved::task))
2267 }
2268 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
2269 Ok(self
2270 .board()
2271 .await?
2272 .items
2273 .iter()
2274 .find(|item| item.id == *id && item.kind == BoardKind::Work(ItemKind::Project))
2275 .map(Resolved::project))
2276 }
2277 async fn query_tasks(
2278 &self,
2279 query: &TaskQuery,
2280 page: &PageRequest,
2281 ) -> Result<Page<Task>, SourceError> {
2282 validate_page(page)?;
2283 let tasks = self
2286 .board()
2287 .await?
2288 .items
2289 .iter()
2290 .filter(|item| item.kind == BoardKind::Work(ItemKind::Task))
2291 .map(Resolved::task)
2292 .filter(|task| task_matches(task, query))
2293 .collect();
2294 Ok(offset_page(
2295 tasks,
2296 numeric_cursor(page.cursor.as_ref())?,
2297 page.limit.min(MAX_PAGE_SIZE) as usize,
2298 ))
2299 }
2300 async fn query_projects(
2301 &self,
2302 query: &ProjectQuery,
2303 page: &PageRequest,
2304 ) -> Result<Page<Project>, SourceError> {
2305 validate_page(page)?;
2306 let projects = self
2307 .board()
2308 .await?
2309 .items
2310 .iter()
2311 .filter(|item| item.kind == BoardKind::Work(ItemKind::Project))
2312 .map(Resolved::project)
2313 .filter(|project| project_matches(project, query))
2314 .collect();
2315 Ok(offset_page(
2316 projects,
2317 numeric_cursor(page.cursor.as_ref())?,
2318 page.limit.min(MAX_PAGE_SIZE) as usize,
2319 ))
2320 }
2321 async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
2322 Ok(self
2323 .board()
2324 .await?
2325 .items
2326 .iter()
2327 .find(|item| item.id == *id && item.kind == BoardKind::Document)
2328 .map(Resolved::document))
2329 }
2330 async fn query_documents(
2331 &self,
2332 query: &DocumentQuery,
2333 page: &PageRequest,
2334 ) -> Result<Page<Document>, SourceError> {
2335 validate_page(page)?;
2336 let documents = self
2339 .board()
2340 .await?
2341 .items
2342 .iter()
2343 .filter(|item| item.kind == BoardKind::Document)
2344 .map(Resolved::document)
2345 .filter(|document| document_matches(document, query))
2346 .collect();
2347 Ok(offset_page(
2348 documents,
2349 numeric_cursor(page.cursor.as_ref())?,
2350 page.limit.min(MAX_PAGE_SIZE) as usize,
2351 ))
2352 }
2353 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
2354 validate_page(page)?;
2355 let offset = numeric_cursor(page.cursor.as_ref())?;
2356 let mut labels = self
2357 .board()
2358 .await?
2359 .items
2360 .into_iter()
2361 .flat_map(|item| item.labels)
2362 .fold(Vec::new(), |mut all, label| {
2363 if !all.iter().any(|x: &Label| x.id == label.id) {
2364 all.push(label);
2365 }
2366 all
2367 });
2368 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
2369 Ok(offset_page(
2370 labels,
2371 offset,
2372 page.limit.min(MAX_PAGE_SIZE) as usize,
2373 ))
2374 }
2375 async fn task_dependencies(
2376 &self,
2377 id: &NativeId,
2378 direction: Direction,
2379 page: &PageRequest,
2380 ) -> Result<Page<DependencyEdge>, SourceError> {
2381 self.dependencies(id, ItemKind::Task, direction, page).await
2382 }
2383 async fn project_dependencies(
2384 &self,
2385 id: &NativeId,
2386 direction: Direction,
2387 page: &PageRequest,
2388 ) -> Result<Page<DependencyEdge>, SourceError> {
2389 self.dependencies(id, ItemKind::Project, direction, page)
2390 .await
2391 }
2392
2393 fn writes(&self) -> WriteSupport {
2394 WriteSupport::Supported
2395 }
2396
2397 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
2398 self.write_item(
2399 &Incoming {
2400 written: Written::Work(ItemKind::Task, &write.item.status),
2401 title: &write.item.title,
2402 content: write.item.content.as_deref(),
2403 labels: &write.item.labels,
2404 metadata: &write.item.metadata,
2405 repositories: &write.item.repositories,
2406 parent: write.item.project.as_ref(),
2407 },
2408 write.target.as_ref(),
2409 &write.depends_on,
2410 )
2411 .await
2412 }
2413
2414 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
2415 self.write_item(
2416 &Incoming {
2417 written: Written::Work(ItemKind::Project, &write.item.status),
2418 title: &write.item.title,
2419 content: write.item.content.as_deref(),
2420 labels: &write.item.labels,
2421 metadata: &write.item.metadata,
2422 repositories: &write.item.repositories,
2423 parent: None,
2424 },
2425 write.target.as_ref(),
2426 &write.depends_on,
2427 )
2428 .await
2429 }
2430
2431 async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
2440 if !write.depends_on.is_empty() {
2445 return Err(SourceError::Refused {
2446 message: format!(
2447 "this write names {} dependencies for a document, and a document takes \
2448 part in no dependency graph; next: put the dependency on the task or \
2449 project the document is about",
2450 write.depends_on.len()
2451 ),
2452 });
2453 }
2454 self.write_item(
2455 &Incoming {
2456 written: Written::Document,
2457 title: &write.item.title,
2458 content: write.item.content.as_deref(),
2459 labels: &write.item.labels,
2460 metadata: &write.item.metadata,
2461 repositories: &write.item.repositories,
2462 parent: write.item.project.as_ref(),
2463 },
2464 write.target.as_ref(),
2465 &[],
2466 )
2467 .await
2468 }
2469
2470 async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
2471 self.delete_item(id).await
2472 }
2473
2474 async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
2475 self.delete_item(id).await
2476 }
2477
2478 async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
2479 self.delete_item(id).await
2480 }
2481}
2482
2483const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
2486
2487const ORIGIN_FIELD: &str = "onetaskgraph.origin";
2492
2493const ORIGIN_KEY: &str = "onetaskgraph.origin";
2507
2508fn recorded_offset(
2516 cursor: Option<&str>,
2517 direction: Direction,
2518) -> Result<Option<usize>, SourceError> {
2519 cursor
2520 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
2521 .map(|offset| {
2522 if direction != Direction::DependsOn {
2523 return Err(SourceError::Config {
2524 message: format!(
2525 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
2526 reverse dependency read never issues; resume it in the direction \
2527 that reported it"
2528 ),
2529 });
2530 }
2531 offset.parse().map_err(|_| SourceError::Config {
2532 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
2533 })
2534 })
2535 .transpose()
2536}
2537
2538fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
2539 let mut page = offset_page(edges, offset, limit.max(1));
2540 page.next = page
2541 .next
2542 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
2543 page
2544}
2545
2546fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
2561 let id = required_str(value, "id")?;
2562 if required_str(value, "title")?.starts_with(DESIGN_TITLE_PREFIX) {
2563 return Err(SourceError::Refused {
2564 message: format!(
2565 "GitHub issue {id} is a document of this board — its title begins \
2566 {DESIGN_TITLE_PREFIX:?} — and nothing may depend on a document or be depended \
2567 on by one; next: remove that issue's blocking relationship on this board"
2568 ),
2569 });
2570 }
2571 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
2572 if parent.is_some() {
2573 return Ok(ItemKind::Task);
2574 }
2575 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
2576 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
2577 message: format!("GitHub issue {id}: {message}"),
2578 })?;
2579 let sub_issues = sub_issue_total(value)?;
2580 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
2581 ItemKind::Project
2582 } else {
2583 ItemKind::Task
2584 })
2585}
2586
2587fn state_input(target: Option<&StatusTarget>) -> Value {
2594 match target {
2595 Some(StatusTarget::Closed(reason)) => {
2596 json!({"value":"CLOSED","stateReason":reason.reason()})
2597 }
2598 Some(StatusTarget::Column(_) | StatusTarget::Disabled) => json!({"value":"OPEN"}),
2599 None => Value::Null,
2603 }
2604}
2605
2606fn slot_metadata(
2613 incoming: &Incoming<'_>,
2614 own_repository: Option<&Repository>,
2615 fallback: &[DependencyEdge],
2616) -> BTreeMap<String, Value> {
2617 let mut metadata = incoming.metadata.clone();
2618 metadata.remove(ORIGIN_KEY);
2619 match incoming.written.kind() {
2620 BoardKind::Work(kind) => metadata.insert(
2621 ItemKind::METADATA_KEY.to_owned(),
2622 Value::String(kind.marker().to_owned()),
2623 ),
2624 BoardKind::Document => metadata.remove(ItemKind::METADATA_KEY),
2627 };
2628 let derivable = own_repository
2629 .map(|own| incoming.repositories == [own.clone()])
2630 .unwrap_or(incoming.repositories.is_empty());
2631 if derivable {
2632 metadata.remove(Repository::METADATA_KEY);
2633 } else {
2634 metadata.insert(
2635 Repository::METADATA_KEY.to_owned(),
2636 Value::Array(
2637 incoming
2638 .repositories
2639 .iter()
2640 .map(|repository| Value::String(repository.as_str().to_owned()))
2641 .collect(),
2642 ),
2643 );
2644 }
2645 if fallback.is_empty() {
2646 metadata.remove(DependencyEdge::RECORDED_KEY);
2647 } else {
2648 metadata.insert(
2649 DependencyEdge::RECORDED_KEY.to_owned(),
2650 Value::Array(
2651 fallback
2652 .iter()
2653 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
2654 .collect(),
2655 ),
2656 );
2657 }
2658 metadata
2659}
2660
2661fn labels(content: &Value, field_values: &[Value]) -> Result<Vec<Label>, SourceError> {
2662 let direct = optional_nodes(content.get("labels"), "content labels")?;
2663 let field = field_values
2664 .iter()
2665 .find_map(|value| value.get("labels"))
2666 .map(|labels| optional_nodes(Some(labels), "field labels"))
2667 .transpose()?
2668 .flatten();
2669 let labels = direct
2670 .into_iter()
2671 .flatten()
2672 .chain(field.into_iter().flatten())
2673 .map(|v| {
2674 Ok(Label {
2675 id: NativeId(required_str(v, "id")?.to_owned()),
2676 name: required_str(v, "name")?.to_owned(),
2677 color: optional_str(v, "color")?.map(str::to_owned),
2678 })
2679 })
2680 .collect::<Result<Vec<_>, SourceError>>()?
2681 .into_iter()
2682 .fold(Vec::new(), |mut labels, label| {
2683 if !labels.iter().any(|x: &Label| x.id == label.id) {
2684 labels.push(label);
2685 }
2686 labels
2687 });
2688 Ok(labels)
2689}
2690
2691fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
2692 let Some(node) = field_values
2693 .iter()
2694 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
2695 else {
2696 return Ok(None);
2697 };
2698 Ok(optional_str(node, "text")?.map(str::to_owned))
2699}
2700
2701fn valid_github_owner(owner: &str) -> bool {
2702 !owner.is_empty()
2703 && owner.len() <= 39
2704 && !owner.starts_with('-')
2705 && !owner.ends_with('-')
2706 && !owner.contains("--")
2707 && owner
2708 .bytes()
2709 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2710}
2711
2712fn valid_github_repository_name(name: &str) -> bool {
2715 !name.is_empty()
2716 && name.len() <= 100
2717 && name != "."
2718 && name != ".."
2719 && name
2720 .bytes()
2721 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2722}
2723
2724fn valid_environment_name(name: &str) -> bool {
2725 let mut bytes = name.bytes();
2726 bytes
2727 .next()
2728 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2729 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2730}
2731
2732fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
2739 let summary = issue
2740 .get("subIssuesSummary")
2741 .ok_or_else(|| SourceError::Malformed {
2742 message: "GitHub issue is missing subIssuesSummary".into(),
2743 })?;
2744 summary
2745 .get("total")
2746 .and_then(Value::as_u64)
2747 .ok_or_else(|| SourceError::Malformed {
2748 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
2749 })
2750}
2751
2752fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2753 value
2754 .get(field)
2755 .and_then(Value::as_str)
2756 .ok_or_else(|| SourceError::Malformed {
2757 message: format!("GitHub response is missing string field {field}"),
2758 })
2759}
2760
2761const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2769const METADATA_CLOSE: &str = "\n-->";
2770
2771fn metadata_body(
2777 body: Option<String>,
2778) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
2779 let Some(body) = body else {
2780 return Ok((None, BTreeMap::new()));
2781 };
2782 let Some(start) = body.rfind(METADATA_OPEN) else {
2783 return Ok((Some(body), BTreeMap::new()));
2784 };
2785 let encoded_start = start + METADATA_OPEN.len();
2786 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
2787 return Err(SourceError::Malformed {
2788 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
2789 });
2790 };
2791 let encoded_end = encoded_start + relative_end;
2792 if !body[encoded_end + METADATA_CLOSE.len()..].trim().is_empty() {
2793 return Ok((Some(body), BTreeMap::new()));
2794 }
2795 let metadata = serde_json::from_str(&body[encoded_start..encoded_end]).map_err(|error| {
2796 SourceError::Malformed {
2797 message: format!(
2798 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
2799 ),
2800 }
2801 })?;
2802 let visible = body[..start].trim_end();
2803 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2804}
2805
2806fn compose_body(
2807 content: Option<&str>,
2808 metadata: &BTreeMap<String, Value>,
2809) -> Result<Option<String>, SourceError> {
2810 let visible = content.unwrap_or_default();
2811 if metadata.is_empty() {
2812 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2813 }
2814 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
2815 message: error.to_string(),
2816 })?;
2817 Ok(Some(if visible.is_empty() {
2818 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2819 } else {
2820 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2821 }))
2822}
2823
2824fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
2825 value
2826 .get(field)
2827 .and_then(Value::as_bool)
2828 .ok_or_else(|| SourceError::Malformed {
2829 message: format!("GitHub response is missing boolean field {field}"),
2830 })
2831}
2832fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
2833 match value.get(field) {
2834 None | Some(Value::Null) => Ok(None),
2835 Some(value) => value
2836 .as_str()
2837 .map(Some)
2838 .ok_or_else(|| SourceError::Malformed {
2839 message: format!("GitHub response field {field} is not a string or null"),
2840 }),
2841 }
2842}
2843fn optional_nodes<'a>(
2844 connection: Option<&'a Value>,
2845 name: &str,
2846) -> Result<Option<&'a Vec<Value>>, SourceError> {
2847 match connection {
2848 None | Some(Value::Null) => Ok(None),
2849 Some(value) => value
2850 .get("nodes")
2851 .and_then(Value::as_array)
2852 .map(Some)
2853 .ok_or_else(|| SourceError::Malformed {
2854 message: format!("GitHub {name}.nodes is not an array"),
2855 }),
2856 }
2857}
2858fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
2859 let page_info = connection
2860 .get("pageInfo")
2861 .ok_or_else(|| SourceError::Malformed {
2862 message: format!("GitHub {name} has no pageInfo"),
2863 })?;
2864 if required_bool(page_info, "hasNextPage")? {
2865 return Err(SourceError::Malformed {
2866 message: format!(
2867 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
2868 ),
2869 });
2870 }
2871 Ok(())
2872}
2873fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2874 optional_str(value, field)?
2875 .map(|timestamp| {
2876 timestamp.parse().map_err(|error| SourceError::Malformed {
2877 message: format!("GitHub response field {field} is not a timestamp: {error}"),
2878 })
2879 })
2880 .transpose()
2881}
2882fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
2883 if page.limit == 0 {
2884 Err(SourceError::Config {
2885 message: "page limit must be at least 1".into(),
2886 })
2887 } else {
2888 Ok(())
2889 }
2890}
2891fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
2892 let page = connection
2893 .get("pageInfo")
2894 .filter(|value| value.is_object())
2895 .ok_or_else(|| SourceError::Malformed {
2896 message: "GitHub connection is missing pageInfo".into(),
2897 })?;
2898 if required_bool(page, "hasNextPage")? {
2899 let cursor = required_str(page, "endCursor")?;
2900 validate_cursor_progress(None, cursor)?;
2901 Ok(Some(Cursor(cursor.into())))
2902 } else {
2903 Ok(None)
2904 }
2905}
2906fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
2907 if next.is_empty() || previous == Some(next) {
2908 Err(SourceError::Malformed {
2909 message: "GitHub pagination cursor is empty or did not advance".into(),
2910 })
2911 } else {
2912 Ok(())
2913 }
2914}
2915fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
2916 cursor.map_or(Ok(0), |c| {
2917 c.0.parse().map_err(|_| SourceError::Config {
2918 message: "page cursor is invalid".into(),
2919 })
2920 })
2921}
2922fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
2923 if offset > items.len() {
2924 return Page::last(vec![]);
2925 }
2926 let tail = items.split_off(offset);
2927 let mut selected = tail;
2928 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
2929 selected.truncate(limit);
2930 Page {
2931 items: selected,
2932 next,
2933 }
2934}