1#![deny(missing_docs)]
117
118use std::collections::BTreeMap;
119
120use chrono::{DateTime, Utc};
121use onetaskgraph_plugin_api::{
122 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
123 Direction, Health, ItemKind, ItemWrite, Label, LabelFilter, NativeId, Page, PageRequest,
124 Project, ProjectFilter, ProjectQuery, Repository, SecretResolver, SourceError, SourceName,
125 SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskSource, TextFields,
126 TextQuery, WriteSupport,
127};
128use reqwest::{Client, StatusCode, Url};
129use schemars::{Schema, schema_for};
130use secrecy::{ExposeSecret, SecretString};
131use serde::Deserialize;
132use serde_json::{Value, json};
133
134pub const KIND: &str = "github-projects";
136pub const MAX_PAGE_SIZE: u32 = 100;
138const NESTED_PAGE_SIZE: u32 = 50;
140
141pub mod graphql {
148 pub const BOARD: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
150 owner:repositoryOwner(login:$owner){
151 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
152 }
153 } fragment Board on ProjectV2 { id title
154 fields(first:$nestedFirst){nodes{
155 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
156 ... on ProjectV2Field{__typename id name}
157 }pageInfo{hasNextPage}}
158 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
159 ... on ProjectV2ItemFieldSingleSelectValue{name field{
160 ... on ProjectV2SingleSelectField{id name options{id name}}
161 }}
162 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
163 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
164 }pageInfo{hasNextPage}} content{
165 ... 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}}}
166 ... on PullRequest{__typename id}
167 ... on DraftIssue{__typename id title body createdAt updatedAt}
168 }} pageInfo{hasNextPage endCursor}}
169 }"#;
170 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
172 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
174 ... on Issue{
175 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
176 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
177 }}} fragment Related on Issue{id body parent{id} subIssuesSummary{total}}"#;
178 pub const CREATE_ISSUE: &str =
180 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id}}}"#;
181 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
183 pub const UPDATE_ISSUE: &str =
185 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
186 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
188 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
190 pub const ADD_SUB_ISSUE: &str =
192 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
193 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
195 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
197 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
199}
200
201fn default_token_env() -> String {
202 "GH_PROJECTS_TOKEN".to_owned()
203}
204fn default_endpoint() -> String {
205 "https://api.github.com/graphql".to_owned()
206}
207
208#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
213#[serde(untagged)]
214pub enum StatusTargetConfig {
215 Column(ColumnName),
217 Closed {
219 closed: ClosedState,
221 },
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
229#[serde(try_from = "String")]
230pub struct ColumnName(String);
231
232impl ColumnName {
233 fn as_str(&self) -> &str {
235 &self.0
236 }
237}
238
239impl TryFrom<String> for ColumnName {
240 type Error = String;
241
242 fn try_from(name: String) -> Result<Self, Self::Error> {
243 if name.trim().is_empty() {
244 return Err("a status_mapping option name cannot be blank".to_owned());
245 }
246 Ok(Self(name))
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
255#[serde(rename_all = "kebab-case")]
256pub enum ClosedState {
257 Completed,
259 NotPlanned,
261}
262
263impl ClosedState {
264 const fn reason(self) -> &'static str {
265 match self {
266 Self::Completed => "COMPLETED",
267 Self::NotPlanned => "NOT_PLANNED",
268 }
269 }
270}
271
272#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
274#[serde(default, deny_unknown_fields)]
275pub struct GitHubProjectsConfig {
276 pub owner: String, pub project_number: u32, pub repository: Option<String>, #[serde(default = "default_token_env")]
289 pub token_env: String, #[serde(default = "default_endpoint")]
292 pub endpoint: String, #[serde(default)]
300 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, }
302
303#[derive(Debug, Clone, Copy, Default)]
305pub struct Plugin;
306
307impl SourcePlugin for Plugin {
308 fn kind(&self) -> &'static str {
309 KIND
310 }
311 fn config_schema(&self) -> Schema {
312 schema_for!(GitHubProjectsConfig)
313 }
314 fn build(
315 &self,
316 name: &SourceName,
317 config: &Value,
318 secrets: &dyn SecretResolver,
319 ) -> Result<Box<dyn TaskSource>, SourceError> {
320 let config: GitHubProjectsConfig =
321 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
322 message: format!("source {name}: {e}"),
323 })?;
324 let source =
325 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
326 SourceError::Config { message } => SourceError::Config {
327 message: format!("source {name}: {message}"),
328 },
329 SourceError::Auth { message } => SourceError::Auth {
330 message: format!("source {name}: {message}"),
331 },
332 other => other,
333 })?;
334 Ok(Box::new(source))
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340enum StatusTarget {
341 Disabled,
343 Column(ColumnName),
345 Closed(ClosedState),
347}
348
349pub const CATEGORIES: [StatusCategory; 7] = [
359 StatusCategory::Draft,
360 StatusCategory::Backlog,
361 StatusCategory::Todo,
362 StatusCategory::InProgress,
363 StatusCategory::Done,
364 StatusCategory::Cancelled,
365 StatusCategory::Unknown,
366];
367
368#[must_use]
370pub const fn category_position(category: StatusCategory) -> usize {
371 match category {
372 StatusCategory::Draft => 0,
373 StatusCategory::Backlog => 1,
374 StatusCategory::Todo => 2,
375 StatusCategory::InProgress => 3,
376 StatusCategory::Done => 4,
377 StatusCategory::Cancelled => 5,
378 StatusCategory::Unknown => 6,
379 }
380}
381
382fn category_name(category: StatusCategory) -> &'static str {
384 match category {
385 StatusCategory::Draft => "draft",
386 StatusCategory::Backlog => "backlog",
387 StatusCategory::Todo => "todo",
388 StatusCategory::InProgress => "in-progress",
389 StatusCategory::Done => "done",
390 StatusCategory::Cancelled => "cancelled",
391 StatusCategory::Unknown => "unknown",
392 }
393}
394
395fn shipped_column(name: &'static str) -> ColumnName {
400 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
401}
402
403fn shipped_default(category: StatusCategory) -> StatusTarget {
405 match category {
406 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
407 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
408 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
409 StatusCategory::Done => StatusTarget::Closed(ClosedState::Completed),
410 StatusCategory::Cancelled => StatusTarget::Closed(ClosedState::NotPlanned),
411 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
412 }
413}
414
415#[derive(Debug, Clone)]
421struct StatusMapping {
422 targets: [StatusTarget; CATEGORIES.len()],
423}
424
425impl StatusMapping {
426 fn resolve(
427 configured: BTreeMap<String, Option<StatusTargetConfig>>,
428 instance: &SourceName,
429 ) -> Result<Self, SourceError> {
430 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
431 for (key, value) in configured {
432 let category = CATEGORIES
433 .iter()
434 .find(|category| category_name(**category) == key)
435 .ok_or_else(|| SourceError::Config {
436 message: format!(
437 "status_mapping names {key:?}, which is not a status category of source \
438 {instance}; the categories are {}",
439 CATEGORIES
440 .iter()
441 .map(|category| category_name(*category))
442 .collect::<Vec<_>>()
443 .join(", ")
444 ),
445 })?;
446 overrides.insert(category_name(*category), value);
447 }
448 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
451 None => shipped_default(category),
452 Some(None) => StatusTarget::Disabled,
453 Some(Some(StatusTargetConfig::Column(option))) => StatusTarget::Column(option),
454 Some(Some(StatusTargetConfig::Closed { closed })) => StatusTarget::Closed(closed),
455 });
456 let mapping = Self { targets };
457 for (index, category) in CATEGORIES.into_iter().enumerate() {
458 let StatusTarget::Column(option) = mapping.target(category) else {
459 continue;
460 };
461 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
462 matches!(mapping.target(**earlier), StatusTarget::Column(name)
463 if name.as_str().eq_ignore_ascii_case(option.as_str()))
464 }) {
465 return Err(SourceError::Config {
466 message: format!(
467 "status_mapping of source {instance} sends both {} and {} to the board \
468 option {:?}; one option cannot read back as two categories",
469 category_name(*other),
470 category_name(category),
471 option.as_str()
472 ),
473 });
474 }
475 }
476 Ok(mapping)
477 }
478
479 fn target(&self, category: StatusCategory) -> &StatusTarget {
480 &self.targets[category_position(category)]
481 }
482
483 fn category_of(&self, option: &str) -> Option<StatusCategory> {
485 CATEGORIES.into_iter().find(|category| {
486 matches!(self.target(*category), StatusTarget::Column(name)
487 if name.as_str().eq_ignore_ascii_case(option))
488 })
489 }
490}
491
492#[derive(Debug, Clone)]
494struct RepositoryTarget {
495 owner: String, name: String, }
498
499impl RepositoryTarget {
500 fn parse(value: &str) -> Result<Self, SourceError> {
501 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
502 message: format!(
503 "repository must be spelled owner/name; {value:?} names no repository"
504 ),
505 })?;
506 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
507 return Err(SourceError::Config {
508 message: format!(
509 "repository must be spelled owner/name with a GitHub login and one \
510 repository name; {value:?} is not"
511 ),
512 });
513 }
514 Ok(Self {
515 owner: owner.to_owned(),
516 name: name.to_owned(),
517 })
518 }
519
520 fn origin(&self) -> String {
521 format!("github.com/{}/{}", self.owner, self.name)
522 }
523}
524
525pub struct GitHubProjectsSource {
527 name: SourceName,
531 owner: String, project_number: u32, repository: Option<RepositoryTarget>,
534 endpoint: Url,
535 token: SecretString,
536 credential_name: String, statuses: StatusMapping,
538 client: Client,
539}
540
541impl GitHubProjectsSource {
542 pub fn new(
549 name: &SourceName,
550 config: GitHubProjectsConfig,
551 secrets: &dyn SecretResolver,
552 ) -> Result<Self, SourceError> {
553 if !valid_github_owner(&config.owner) {
554 return Err(SourceError::Config {
555 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
556 });
557 }
558 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
559 return Err(SourceError::Config {
560 message: format!("project_number must be between 1 and {}", i32::MAX),
561 });
562 }
563 if !valid_environment_name(&config.token_env) {
564 return Err(SourceError::Config {
565 message: "token_env must be a valid environment-variable name".into(),
566 });
567 }
568 let repository = config
569 .repository
570 .as_deref()
571 .map(RepositoryTarget::parse)
572 .transpose()?;
573 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
574 message: format!("endpoint is not a valid URL: {e}"),
575 })?;
576 if endpoint.scheme() != "https"
577 && !(endpoint.scheme() == "http"
578 && endpoint
579 .host_str()
580 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
581 {
582 return Err(SourceError::Config {
583 message:
584 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
585 .into(),
586 });
587 }
588 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
589 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),
590 })?;
591 Ok(Self {
592 name: name.clone(),
593 owner: config.owner,
594 project_number: config.project_number,
595 repository,
596 endpoint,
597 token,
598 credential_name: config.token_env,
599 statuses: StatusMapping::resolve(config.status_mapping, name)?,
600 client: Client::builder()
601 .user_agent("onetaskgraph")
602 .build()
603 .map_err(|e| SourceError::Config {
604 message: format!("cannot build HTTP client: {e}"),
605 })?,
606 })
607 }
608
609 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
610 let response = self
611 .client
612 .post(self.endpoint.clone())
613 .bearer_auth(self.token.expose_secret())
614 .json(&json!({"query": query, "variables": variables}))
615 .send()
616 .await
617 .map_err(|e| SourceError::Unavailable {
618 message: format!("GitHub GraphQL request failed: {e}"),
619 })?;
620 let status = response.status();
621 let retry_after = response
622 .headers()
623 .get("retry-after")
624 .and_then(|v| v.to_str().ok())
625 .and_then(|v| v.parse().ok());
626 let exhausted = response
627 .headers()
628 .get("x-ratelimit-remaining")
629 .and_then(|v| v.to_str().ok())
630 == Some("0");
631 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
632 return Err(SourceError::RateLimited {
633 retry_after_seconds: retry_after,
634 });
635 }
636 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
637 return Err(SourceError::Auth {
638 message: format!(
639 "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"
640 ),
641 });
642 }
643 if !status.is_success() {
644 return Err(SourceError::Unavailable {
645 message: format!("GitHub GraphQL returned HTTP {status}"),
646 });
647 }
648 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
649 message: format!("GitHub returned invalid JSON: {e}"),
650 })?;
651 let errors = body
652 .get("errors")
653 .map(|value| {
654 value.as_array().ok_or_else(|| SourceError::Malformed {
655 message: "GitHub response errors is not an array".into(),
656 })
657 })
658 .transpose()?;
659 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
660 let messages = errors
661 .iter()
662 .filter_map(|e| e.get("message").and_then(Value::as_str))
663 .collect::<Vec<_>>()
664 .join("; ");
665 let message = if messages.is_empty() {
666 "GitHub returned GraphQL errors".into()
667 } else {
668 messages
669 };
670 let normalized = message.to_ascii_lowercase();
671 if normalized.contains("resource not accessible") || normalized.contains("scope") {
672 return Err(SourceError::Auth {
673 message: format!(
674 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
675 self.credential_name
676 ),
677 });
678 }
679 return Err(SourceError::Refused { message });
680 }
681 body.get("data")
682 .filter(|data| data.is_object())
683 .cloned()
684 .ok_or_else(|| SourceError::Malformed {
685 message: "GitHub response has no data object".into(),
686 })
687 }
688
689 async fn board_page(
693 &self,
694 items_after: Option<&str>,
695 items_first: u32,
696 ) -> Result<Value, SourceError> {
697 let data = self
698 .graphql(
699 graphql::BOARD,
700 json!({"owner":self.owner,"number":self.project_number,
701 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
702 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
703 )
704 .await?;
705 data.pointer("/owner/projectV2")
706 .filter(|v| !v.is_null())
707 .cloned()
708 .ok_or_else(|| SourceError::Refused {
709 message: format!(
710 "GitHub project {}/{} was not found or is not visible to the token",
711 self.owner, self.project_number
712 ),
713 })
714 }
715
716 async fn board(&self) -> Result<Board, SourceError> {
718 let mut after: Option<String> = None;
719 let mut items = Vec::new();
720 let mut board;
721 loop {
722 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
723 for item in page
724 .pointer("/items/nodes")
725 .and_then(Value::as_array)
726 .ok_or_else(|| SourceError::Malformed {
727 message: "GitHub project items.nodes is not an array".into(),
728 })?
729 {
730 if let Some(resolved) = self.resolve(item)? {
731 items.push(resolved);
732 }
733 }
734 let info = page
735 .pointer("/items/pageInfo")
736 .ok_or_else(|| SourceError::Malformed {
737 message: "GitHub project items have no pageInfo".into(),
738 })?;
739 let has_next = required_bool(info, "hasNextPage")?;
740 let next = has_next
741 .then(|| required_str(info, "endCursor"))
742 .transpose()?;
743 board = page.clone();
744 match next {
745 Some(next) => {
746 validate_cursor_progress(after.as_deref(), next)?;
747 after = Some(next.to_owned());
748 }
749 None => break,
750 }
751 }
752 Ok(Board {
753 id: required_str(&board, "id")?.to_owned(),
754 fields: board.get("fields").cloned().unwrap_or(Value::Null),
755 items,
756 })
757 }
758
759 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
765 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
766 message: "GitHub project item is missing content".into(),
767 })?;
768 if content.is_null() {
769 return Ok(None);
770 }
771 let content_kind = match required_str(content, "__typename")? {
772 "Issue" => ContentKind::Issue,
773 "DraftIssue" => ContentKind::DraftIssue,
774 _ => return Ok(None),
775 };
776 let field_values = item
777 .get("fieldValues")
778 .ok_or_else(|| SourceError::Malformed {
779 message: "GitHub project item is missing fieldValues".into(),
780 })?;
781 complete_connection(field_values, "project item field values")?;
782 let nodes = field_values
783 .get("nodes")
784 .and_then(Value::as_array)
785 .ok_or_else(|| SourceError::Malformed {
786 message: "GitHub project item fieldValues.nodes is not an array".into(),
787 })?;
788 if let Some(labels) = content.get("labels") {
789 complete_connection(labels, "content labels")?;
790 }
791 for field_value in nodes {
792 if let Some(labels) = field_value.get("labels") {
793 complete_connection(labels, "project item field labels")?;
794 }
795 }
796 let (body, slot) = metadata_body(optional_str(content, "body")?.map(str::to_owned))?;
797 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
798 .map(|id| NativeId(id.to_owned()));
799 let sub_issues = match content_kind {
802 ContentKind::Issue => sub_issue_total(content)?,
803 ContentKind::DraftIssue => 0,
804 };
805 let content_id = required_str(content, "id")?;
806 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
807 message: format!("GitHub issue {content_id}: {message}"),
808 })?;
809 let kind = if parent.is_some() {
812 ItemKind::Task
813 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
814 ItemKind::Project
815 } else {
816 ItemKind::Task
817 };
818 let own_repository = content
819 .pointer("/repository/nameWithOwner")
820 .and_then(Value::as_str)
821 .map(|origin| Repository::try_from(format!("github.com/{origin}")))
822 .transpose()
823 .map_err(|message| SourceError::Malformed { message })?;
824 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
825 Repository::from_metadata(&slot)
826 .map_err(|message| SourceError::Malformed { message })?
827 } else {
828 own_repository.clone().into_iter().collect()
829 };
830 Ok(Some(Resolved {
831 item_id: required_str(item, "id")?.to_owned(),
832 id: NativeId(content_id.to_owned()),
833 content_kind,
834 kind,
835 title: required_str(content, "title")?.to_owned(),
836 body: body.filter(|value| !value.is_empty()),
837 status: self.status(item, content)?,
838 labels: labels(content, nodes)?,
839 parent,
840 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
841 url: optional_str(content, "url")?.map(str::to_owned),
842 created_at: optional_time(content, "createdAt")?,
843 updated_at: optional_time(content, "updatedAt")?,
844 own_repository,
845 repositories,
846 slot,
847 }))
848 }
849
850 fn status(&self, item: &Value, content: &Value) -> Result<Status, SourceError> {
860 let nodes = item
861 .pointer("/fieldValues/nodes")
862 .and_then(Value::as_array)
863 .expect("resolve validates fieldValues.nodes before mapping status");
864 let option = nodes
865 .iter()
866 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
867 .map(|value| required_str(value, "name"))
868 .transpose()?;
869 let state = optional_str(content, "state")?;
870 if state == Some("CLOSED") {
871 let category = match optional_str(content, "stateReason")? {
872 None | Some("COMPLETED") => StatusCategory::Done,
873 Some("NOT_PLANNED") => StatusCategory::Cancelled,
874 Some(_) => StatusCategory::Unknown,
875 };
876 let fallback = match category {
877 StatusCategory::Done => "Done",
878 StatusCategory::Cancelled => "Cancelled",
879 _ => "Closed",
880 };
881 return Ok(Status {
882 category,
883 name: option.unwrap_or(fallback).to_owned(),
884 });
885 }
886 let name = option.unwrap_or("Open").to_owned();
887 Ok(Status {
888 category: self
889 .statuses
890 .category_of(&name)
891 .unwrap_or(StatusCategory::Unknown),
892 name,
893 })
894 }
895
896 fn column_for(
904 &self,
905 board: &Board,
906 status: &Status,
907 target: &StatusTarget,
908 ) -> Result<Option<(String, String)>, SourceError> {
909 let (wanted, required) = match target {
910 StatusTarget::Column(wanted) => (wanted.as_str(), true),
911 StatusTarget::Closed(_) => (status.name.as_str(), false),
912 StatusTarget::Disabled => return Ok(None),
913 };
914 let missing = |detail: &str| SourceError::Refused {
915 message: format!(
916 "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",
917 category_name(status.category),
918 self.name,
919 category_name(status.category)
920 ),
921 };
922 let Some(field) = Board::field(&board.fields, "Status")? else {
923 return if required {
924 Err(missing("this board has no Status field"))
925 } else {
926 Ok(None)
927 };
928 };
929 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
930 return if required {
931 Err(missing(
932 "this board's Status field is not a single-select field",
933 ))
934 } else {
935 Ok(None)
936 };
937 }
938 let option = field
939 .get("options")
940 .and_then(Value::as_array)
941 .and_then(|options| {
942 options.iter().find(|option| {
943 option
944 .get("name")
945 .and_then(Value::as_str)
946 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
947 })
948 });
949 match option {
950 None if required => Err(missing("this board does not have it")),
951 None => Ok(None),
952 Some(option) => Ok(Some((
953 required_str(field, "id")?.to_owned(),
954 required_str(option, "id")?.to_owned(),
955 ))),
956 }
957 }
958
959 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
966 let target = self.statuses.target(category).clone();
967 if target != StatusTarget::Disabled {
968 return Ok(target);
969 }
970 Err(SourceError::Refused {
971 message: if category == StatusCategory::Draft {
972 format!(
973 "status draft is disabled for source {}: draft is incompatible with this \
974 integration because GitHub draft issues cannot have sub-issues, and this \
975 source stores a project's tasks as its issue's sub-issues",
976 self.name
977 )
978 } else {
979 format!(
980 "status {} is disabled for source {}; set status_mapping.{} of this source \
981 to a board Status option name or to a closed state",
982 category_name(category),
983 self.name,
984 category_name(category)
985 )
986 },
987 })
988 }
989
990 async fn set_item_field(
991 &self,
992 board_id: &str,
993 item_id: &str,
994 field_id: &str,
995 value: Value,
996 ) -> Result<(), SourceError> {
997 let data = self
998 .graphql(
999 graphql::UPDATE_FIELD,
1000 json!({"input":{
1001 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
1002 }}),
1003 )
1004 .await?;
1005 let returned = data
1006 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
1007 .ok_or_else(|| SourceError::Malformed {
1008 message: "GitHub field update returned no project item".into(),
1009 })?;
1010 if required_str(returned, "id")? != item_id {
1011 return Err(SourceError::Malformed {
1012 message: "GitHub field update returned the wrong project item".into(),
1013 });
1014 }
1015 Ok(())
1016 }
1017
1018 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
1019 let mut after: Option<String> = None;
1020 let mut ids = Vec::new();
1021 loop {
1022 let data = self
1023 .graphql(
1024 graphql::ISSUE_DEPENDENCIES,
1025 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
1026 )
1027 .await?;
1028 let connection =
1029 data.pointer("/node/blockedBy")
1030 .ok_or_else(|| SourceError::Malformed {
1031 message: "GitHub dependency response has no blockedBy connection".into(),
1032 })?;
1033 ids.extend(
1034 connection
1035 .get("nodes")
1036 .and_then(Value::as_array)
1037 .ok_or_else(|| SourceError::Malformed {
1038 message: "GitHub dependency response nodes is not an array".into(),
1039 })?
1040 .iter()
1041 .map(|value| required_str(value, "id").map(str::to_owned))
1042 .collect::<Result<Vec<_>, _>>()?,
1043 );
1044 let next = next_cursor(connection)?;
1045 if let Some(next) = &next {
1046 validate_cursor_progress(after.as_deref(), &next.0)?;
1047 }
1048 after = next.map(|cursor| cursor.0);
1049 if after.is_none() {
1050 return Ok(ids);
1051 }
1052 }
1053 }
1054
1055 async fn dependencies(
1056 &self,
1057 id: &NativeId,
1058 near_kind: ItemKind,
1059 direction: Direction,
1060 page: &PageRequest,
1061 ) -> Result<Page<DependencyEdge>, SourceError> {
1062 validate_page(page)?;
1063 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
1064 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1065 let recorded = recorded_offset(cursor, direction)?;
1066 let data = self
1071 .graphql(
1072 graphql::ISSUE_DEPENDENCIES,
1073 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
1074 "after":if recorded.is_some() {None} else {cursor}}),
1075 )
1076 .await?;
1077 let node =
1078 data.get("node")
1079 .filter(|v| !v.is_null())
1080 .ok_or_else(|| SourceError::Refused {
1081 message: format!(
1082 "GitHub item {} was not found or does not support dependencies",
1083 id.0
1084 ),
1085 })?;
1086 let connection_name = match direction {
1087 Direction::DependsOn => "blockedBy",
1088 Direction::DependedOnBy => "blocking",
1089 };
1090 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
1094 if let Some(offset) = recorded {
1095 return Ok(recorded_page(
1096 self.recorded_edges(id, near_kind, direction, natively_names)
1097 .await?,
1098 offset,
1099 limit,
1100 ));
1101 }
1102 if natively_names.is_none() {
1103 return Ok(recorded_page(
1104 self.recorded_edges(id, near_kind, direction, natively_names)
1105 .await?,
1106 0,
1107 limit,
1108 ));
1109 }
1110 let connection = node
1111 .get(connection_name)
1112 .ok_or_else(|| SourceError::Malformed {
1113 message: "GitHub dependency response is missing its connection".into(),
1114 })?;
1115 let nodes = connection
1116 .get("nodes")
1117 .and_then(Value::as_array)
1118 .ok_or_else(|| SourceError::Malformed {
1119 message: "GitHub dependency response nodes is not an array".into(),
1120 })?;
1121 let items = nodes
1125 .iter()
1126 .map(|value| {
1127 let related = NativeId(required_str(value, "id")?.into());
1128 let related_kind = related_kind(value)?;
1129 let (from, to) = match direction {
1130 Direction::DependsOn => (
1131 DependencyEndpoint::from_native(id.clone(), near_kind),
1132 DependencyEndpoint::from_native(related, related_kind),
1133 ),
1134 Direction::DependedOnBy => (
1135 DependencyEndpoint::from_native(related, related_kind),
1136 DependencyEndpoint::from_native(id.clone(), near_kind),
1137 ),
1138 };
1139 Ok(DependencyEdge {
1140 from,
1141 to,
1142 kind: DependencyKind::Blocks,
1143 })
1144 })
1145 .collect::<Result<Vec<_>, SourceError>>()?;
1146 let mut next = next_cursor(connection)?;
1147 if let Some(next) = &next {
1148 validate_cursor_progress(cursor, &next.0)?;
1149 }
1150 if next.is_none()
1151 && !self
1152 .recorded_edges(id, near_kind, direction, natively_names)
1153 .await?
1154 .is_empty()
1155 {
1156 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1157 }
1158 Ok(Page { items, next })
1159 }
1160
1161 async fn recorded_edges(
1171 &self,
1172 id: &NativeId,
1173 near_kind: ItemKind,
1174 direction: Direction,
1175 natively_names: Option<ItemKind>,
1176 ) -> Result<Vec<DependencyEdge>, SourceError> {
1177 if direction != Direction::DependsOn {
1178 return Ok(Vec::new());
1179 }
1180 let Some(item) = self
1181 .board()
1182 .await?
1183 .items
1184 .into_iter()
1185 .find(|item| item.id == *id)
1186 else {
1187 return Ok(Vec::new());
1188 };
1189 DependencyEdge::recorded(&item.slot, id, near_kind, &self.name, natively_names)
1190 .map_err(|message| SourceError::Malformed { message })
1191 }
1192
1193 async fn repository_id(&self) -> Result<String, SourceError> {
1195 let repository = self
1196 .repository
1197 .as_ref()
1198 .ok_or_else(|| SourceError::Refused {
1199 message: format!(
1200 "source {} has no repository configured, and a GitHub Projects board has no \
1201 repository of its own to create an issue in; set repository: owner/name on \
1202 this source",
1203 self.name
1204 ),
1205 })?;
1206 let data = self
1207 .graphql(
1208 graphql::REPOSITORY,
1209 json!({"owner":repository.owner,"name":repository.name}),
1210 )
1211 .await?;
1212 let node = data
1213 .get("repository")
1214 .filter(|value| !value.is_null())
1215 .ok_or_else(|| SourceError::Refused {
1216 message: format!(
1217 "GitHub repository {}/{} was not found or is not visible to the token",
1218 repository.owner, repository.name
1219 ),
1220 })?;
1221 Ok(required_str(node, "id")?.to_owned())
1222 }
1223
1224 async fn write_item(
1226 &self,
1227 incoming: &Incoming<'_>,
1228 target: Option<&NativeId>,
1229 depends_on: &[DependencyEdge],
1230 ) -> Result<NativeId, SourceError> {
1231 let board = self.board().await?;
1232 let status_target = self.resolved_target(incoming.status.category)?;
1233 let column = self.column_for(&board, incoming.status, &status_target)?;
1234 let existing = target
1235 .map(|target| {
1236 board
1237 .items
1238 .iter()
1239 .find(|item| item.id == *target)
1240 .ok_or_else(|| SourceError::Refused {
1241 message: format!("GitHub destination item {} was not found", target.0),
1242 })
1243 })
1244 .transpose()?;
1245 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
1246 if content_kind == ContentKind::DraftIssue {
1247 if let StatusTarget::Closed(_) = status_target {
1248 return Err(SourceError::Refused {
1249 message: format!(
1250 "status {} of source {} closes the item's issue, and GitHub draft items \
1251 have no open or closed state",
1252 category_name(incoming.status.category),
1253 self.name
1254 ),
1255 });
1256 }
1257 if incoming.parent.is_some() {
1258 return Err(SourceError::Refused {
1259 message: "GitHub draft items cannot be a project's sub-issue".into(),
1260 });
1261 }
1262 }
1263 match existing {
1264 Some(item) if content_kind == ContentKind::Issue => {
1265 if item.labels != incoming.labels {
1266 return Err(SourceError::Refused {
1267 message: "GitHub issue labels differ from the labels being written".into(),
1268 });
1269 }
1270 }
1271 _ => {
1272 if !incoming.labels.is_empty() {
1273 return Err(SourceError::Refused {
1274 message: "GitHub items created by this destination carry no labels".into(),
1275 });
1276 }
1277 }
1278 }
1279
1280 let own_repository = match existing {
1281 Some(item) => item.own_repository.clone(),
1282 None => self
1283 .repository
1284 .as_ref()
1285 .map(|repository| Repository::try_from(repository.origin()))
1286 .transpose()
1287 .map_err(|message| SourceError::Config { message })?,
1288 };
1289 let (native, fallback) = self
1290 .partition_edges(&board, incoming.kind, content_kind, depends_on)
1291 .await?;
1292 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
1293 let body = compose_body(incoming.content, &slot)?;
1294 let origin = match incoming.metadata.get(ORIGIN_KEY) {
1301 None => "",
1302 Some(Value::String(origin)) => origin.as_str(),
1303 Some(other) => {
1304 return Err(SourceError::Refused {
1305 message: format!(
1306 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
1307 is {other}"
1308 ),
1309 });
1310 }
1311 };
1312 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
1316 Some(field) => {
1317 if required_str(field, "__typename")? != "ProjectV2Field" {
1318 return Err(SourceError::Refused {
1319 message: format!(
1320 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
1321 ),
1322 });
1323 }
1324 Some(required_str(field, "id")?.to_owned())
1325 }
1326 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
1327 return Err(SourceError::Refused {
1328 message: format!(
1329 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
1330 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
1331 the board"
1332 ),
1333 });
1334 }
1335 None => None,
1336 };
1337
1338 let (content_id, item_id) = match existing {
1339 Some(item) => {
1340 self.update_existing(item, incoming, &body, &status_target)
1341 .await?;
1342 (item.id.clone(), item.item_id.clone())
1343 }
1344 None => {
1345 self.create_and_file_issue(&board, incoming, &body, &status_target)
1346 .await?
1347 }
1348 };
1349
1350 if let Some(field_id) = &origin_field {
1351 self.set_item_field(&board.id, &item_id, field_id, json!({"text":origin}))
1352 .await?;
1353 }
1354
1355 if let Some((field_id, option_id)) = column {
1356 self.set_item_field(
1357 &board.id,
1358 &item_id,
1359 &field_id,
1360 json!({"singleSelectOptionId":option_id}),
1361 )
1362 .await?;
1363 }
1364
1365 if content_kind == ContentKind::Issue {
1366 self.reparent(
1367 existing.and_then(|item| item.parent.clone()),
1368 &content_id,
1369 incoming.parent,
1370 )
1371 .await?;
1372 self.reconcile_blocked_by(&content_id, &native).await?;
1373 }
1374 Ok(content_id)
1375 }
1376
1377 async fn partition_edges(
1379 &self,
1380 board: &Board,
1381 near_kind: ItemKind,
1382 near_content: ContentKind,
1383 depends_on: &[DependencyEdge],
1384 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
1385 let mut native = Vec::new();
1386 let mut fallback = Vec::new();
1387 for edge in depends_on {
1388 let same_source = edge
1389 .to
1390 .source()
1391 .is_none_or(|source| source == self.name.as_str());
1392 let far_id = if edge.to.is_qualified() {
1397 edge.to
1398 .id()
1399 .split_once(':')
1400 .map_or(edge.to.id(), |(_, native)| native)
1401 } else {
1402 edge.to.id()
1403 };
1404 let far = if same_source {
1405 Some(
1406 board
1407 .items
1408 .iter()
1409 .find(|item| item.id.0 == far_id)
1410 .ok_or_else(|| SourceError::Refused {
1411 message: format!("GitHub dependency item {far_id} was not found"),
1412 })?,
1413 )
1414 } else {
1415 None
1416 };
1417 if let Some(disagreeing) = far.filter(|far| far.kind != edge.to.kind) {
1423 return Err(SourceError::Refused {
1424 message: format!(
1425 "GitHub dependency item {far_id} is a {} of this board, and this item \
1426 names it as a {}; record the kind it is",
1427 disagreeing.kind.marker(),
1428 edge.to.kind.marker()
1429 ),
1430 });
1431 }
1432 let native_here = near_content == ContentKind::Issue
1436 && far.is_some_and(|far| {
1437 far.content_kind == ContentKind::Issue && edge.to.kind == near_kind
1438 });
1439 if native_here {
1440 native.push(far_id.to_owned());
1441 } else {
1442 fallback.push(edge.clone());
1443 }
1444 }
1445 Ok((native, fallback))
1446 }
1447
1448 async fn update_existing(
1449 &self,
1450 item: &Resolved,
1451 incoming: &Incoming<'_>,
1452 body: &Option<String>,
1453 status_target: &StatusTarget,
1454 ) -> Result<(), SourceError> {
1455 let (operation, input, pointer) = match item.content_kind {
1456 ContentKind::DraftIssue => (
1457 graphql::UPDATE_DRAFT,
1458 json!({"draftIssueId":item.id.0,"title":incoming.title,"body":body}),
1459 "/updateProjectV2DraftIssue/draftIssue",
1460 ),
1461 ContentKind::Issue => (
1462 graphql::UPDATE_ISSUE,
1463 json!({"id":item.id.0,"title":incoming.title,"body":body,
1464 "stateInput":state_input(status_target)}),
1465 "/updateIssue/issue",
1466 ),
1467 };
1468 let data = self.graphql(operation, json!({"input":input})).await?;
1469 let returned = data
1470 .pointer(pointer)
1471 .ok_or_else(|| SourceError::Malformed {
1472 message: "GitHub item update returned no item".into(),
1473 })?;
1474 if required_str(returned, "id")? != item.id.0 {
1475 return Err(SourceError::Malformed {
1476 message: "GitHub item update returned the wrong item".into(),
1477 });
1478 }
1479 Ok(())
1480 }
1481
1482 async fn create_and_file_issue(
1488 &self,
1489 board: &Board,
1490 incoming: &Incoming<'_>,
1491 body: &Option<String>,
1492 status_target: &StatusTarget,
1493 ) -> Result<(NativeId, String), SourceError> {
1494 let repository_id = self.repository_id().await?;
1495 let data = self
1496 .graphql(
1497 graphql::CREATE_ISSUE,
1498 json!({"input":{
1499 "repositoryId":repository_id,"title":incoming.title,"body":body
1500 }}),
1501 )
1502 .await?;
1503 let created = data
1504 .pointer("/createIssue/issue")
1505 .filter(|value| !value.is_null())
1506 .ok_or_else(|| SourceError::Malformed {
1507 message: "GitHub issue creation returned no issue".into(),
1508 })?;
1509 let content_id = NativeId(required_str(created, "id")?.to_owned());
1510 let added = self
1511 .graphql(
1512 graphql::ADD_TO_BOARD,
1513 json!({"input":{"projectId":board.id,"contentId":content_id.0}}),
1514 )
1515 .await?;
1516 let item = added
1517 .pointer("/addProjectV2ItemById/item")
1518 .filter(|value| !value.is_null())
1519 .ok_or_else(|| SourceError::Malformed {
1520 message: "GitHub board addition returned no project item".into(),
1521 })?;
1522 if let StatusTarget::Closed(_) = status_target {
1523 let closed = self
1524 .graphql(
1525 graphql::UPDATE_ISSUE,
1526 json!({"input":{"id":content_id.0,"stateInput":state_input(status_target)}}),
1527 )
1528 .await?;
1529 let returned =
1530 closed
1531 .pointer("/updateIssue/issue")
1532 .ok_or_else(|| SourceError::Malformed {
1533 message: "GitHub item update returned no item".into(),
1534 })?;
1535 if required_str(returned, "id")? != content_id.0 {
1536 return Err(SourceError::Malformed {
1537 message: "GitHub item update returned the wrong item".into(),
1538 });
1539 }
1540 }
1541 Ok((content_id, required_str(item, "id")?.to_owned()))
1542 }
1543
1544 async fn reparent(
1546 &self,
1547 held: Option<NativeId>,
1548 child: &NativeId,
1549 wanted: Option<&NativeId>,
1550 ) -> Result<(), SourceError> {
1551 if held.as_ref() == wanted {
1552 return Ok(());
1553 }
1554 if let Some(held) = &held {
1555 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
1556 .await?;
1557 }
1558 if let Some(wanted) = wanted {
1559 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
1560 .await?;
1561 }
1562 Ok(())
1563 }
1564
1565 async fn sub_issue(
1566 &self,
1567 operation: &str,
1568 parent: &NativeId,
1569 child: &NativeId,
1570 root: &str,
1571 ) -> Result<(), SourceError> {
1572 let data = self
1573 .graphql(
1574 operation,
1575 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
1576 )
1577 .await?;
1578 let issue =
1579 data.pointer(&format!("/{root}/issue"))
1580 .ok_or_else(|| SourceError::Malformed {
1581 message: "GitHub sub-issue update returned no issue".into(),
1582 })?;
1583 let sub =
1584 data.pointer(&format!("/{root}/subIssue"))
1585 .ok_or_else(|| SourceError::Malformed {
1586 message: "GitHub sub-issue update returned no sub-issue".into(),
1587 })?;
1588 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
1589 return Err(SourceError::Malformed {
1590 message: "GitHub sub-issue update returned the wrong issues".into(),
1591 });
1592 }
1593 Ok(())
1594 }
1595
1596 async fn reconcile_blocked_by(
1597 &self,
1598 content_id: &NativeId,
1599 native: &[String],
1600 ) -> Result<(), SourceError> {
1601 let current = self.native_dependency_ids(content_id).await?;
1602 for (operation, far_id) in current
1603 .iter()
1604 .filter(|id| !native.contains(id))
1605 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1606 .chain(
1607 native
1608 .iter()
1609 .filter(|id| !current.contains(id))
1610 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1611 )
1612 {
1613 let data = self
1614 .graphql(
1615 operation,
1616 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1617 )
1618 .await?;
1619 let root = if operation == graphql::ADD_BLOCKED_BY {
1620 "addBlockedBy"
1621 } else {
1622 "removeBlockedBy"
1623 };
1624 let issue =
1625 data.pointer(&format!("/{root}/issue"))
1626 .ok_or_else(|| SourceError::Malformed {
1627 message: "GitHub dependency update returned no issue".into(),
1628 })?;
1629 let blocker = data
1630 .pointer(&format!("/{root}/blockingIssue"))
1631 .ok_or_else(|| SourceError::Malformed {
1632 message: "GitHub dependency update returned no blocking issue".into(),
1633 })?;
1634 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
1635 {
1636 return Err(SourceError::Malformed {
1637 message: "GitHub dependency update returned the wrong issues".into(),
1638 });
1639 }
1640 }
1641 Ok(())
1642 }
1643}
1644
1645struct Board {
1647 id: String,
1648 fields: Value,
1649 items: Vec<Resolved>,
1650}
1651
1652impl Board {
1653 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
1654 complete_connection(fields, "project fields")?;
1655 let nodes = fields
1656 .get("nodes")
1657 .and_then(Value::as_array)
1658 .ok_or_else(|| SourceError::Malformed {
1659 message: "GitHub project fields.nodes is not an array".into(),
1660 })?;
1661 Ok(nodes
1662 .iter()
1663 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
1664 }
1665}
1666
1667struct Resolved {
1669 item_id: String,
1670 id: NativeId,
1671 content_kind: ContentKind,
1672 kind: ItemKind,
1673 title: String,
1674 body: Option<String>,
1675 status: Status,
1676 labels: Vec<Label>,
1677 parent: Option<NativeId>,
1678 origin: Option<String>,
1680 url: Option<String>,
1681 created_at: Option<DateTime<Utc>>,
1682 updated_at: Option<DateTime<Utc>>,
1683 own_repository: Option<Repository>,
1684 repositories: Vec<Repository>,
1685 slot: BTreeMap<String, Value>,
1686}
1687
1688impl Resolved {
1689 fn metadata(&self) -> BTreeMap<String, Value> {
1692 let mut metadata = self.slot.clone();
1693 metadata.remove(Repository::METADATA_KEY);
1694 metadata.remove(DependencyEdge::RECORDED_KEY);
1695 metadata.remove(ItemKind::METADATA_KEY);
1696 if let Some(origin) = &self.origin {
1697 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
1698 }
1699 metadata
1700 }
1701
1702 fn task(&self) -> Task {
1703 Task {
1704 id: self.id.clone(),
1705 title: self.title.clone(),
1706 content: self.body.clone(),
1707 status: self.status.clone(),
1708 labels: self.labels.clone(),
1709 project: self.parent.clone(),
1710 url: self.url.clone(),
1711 created_at: self.created_at,
1712 updated_at: self.updated_at,
1713 metadata: self.metadata(),
1714 repositories: self.repositories.clone(),
1715 }
1716 }
1717
1718 fn project(&self) -> Project {
1719 Project {
1720 id: self.id.clone(),
1721 title: self.title.clone(),
1722 content: self.body.clone(),
1723 status: self.status.clone(),
1724 labels: self.labels.clone(),
1725 url: self.url.clone(),
1726 created_at: self.created_at,
1727 updated_at: self.updated_at,
1728 metadata: self.metadata(),
1729 repositories: self.repositories.clone(),
1730 }
1731 }
1732}
1733
1734struct Incoming<'a> {
1736 kind: ItemKind,
1737 title: &'a str,
1738 content: Option<&'a str>,
1739 status: &'a Status,
1740 labels: &'a [Label],
1741 metadata: &'a BTreeMap<String, Value>,
1742 repositories: &'a [Repository],
1743 parent: Option<&'a NativeId>,
1744}
1745
1746#[derive(Clone, Copy, PartialEq, Eq)]
1747enum ContentKind {
1748 DraftIssue,
1749 Issue,
1750}
1751
1752fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
1758 let holds = |name: &String| {
1759 labels
1760 .iter()
1761 .any(|label| label.name.eq_ignore_ascii_case(name))
1762 };
1763 (filter.any_of.is_empty() || filter.any_of.iter().any(holds))
1764 && filter.all_of.iter().all(holds)
1765 && !filter.none_of.iter().any(holds)
1766}
1767
1768fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
1771 statuses.is_empty() || statuses.contains(&category)
1772}
1773
1774fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
1780 let terms = query.terms.to_lowercase();
1781 let in_title = title.to_lowercase().contains(&terms);
1782 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
1783 match query.fields {
1784 TextFields::Title => in_title,
1785 TextFields::Content => in_content,
1786 TextFields::TitleOrContent => in_title || in_content,
1787 }
1788}
1789
1790fn task_matches(task: &Task, query: &TaskQuery) -> bool {
1791 labels_match(&task.labels, &query.labels)
1792 && status_matches(task.status.category, &query.statuses)
1793 && match &query.project {
1794 ProjectFilter::Any => true,
1795 ProjectFilter::Orphans => task.project.is_none(),
1796 ProjectFilter::Is(id) => task.project.as_ref() == Some(id),
1797 }
1798 && query
1799 .text
1800 .as_ref()
1801 .is_none_or(|text| text_matches(&task.title, task.content.as_deref(), text))
1802}
1803
1804fn project_matches(project: &Project, query: &ProjectQuery) -> bool {
1805 labels_match(&project.labels, &query.labels)
1806 && status_matches(project.status.category, &query.statuses)
1807 && query
1808 .text
1809 .as_ref()
1810 .is_none_or(|text| text_matches(&project.title, project.content.as_deref(), text))
1811}
1812
1813#[async_trait::async_trait]
1814impl TaskSource for GitHubProjectsSource {
1815 fn kind(&self) -> &'static str {
1816 KIND
1817 }
1818 fn capabilities(&self) -> Capabilities {
1819 Capabilities {
1820 projects: Support::Native,
1821 orphan_tasks: Support::Native,
1822 filter_by_label: Support::Native,
1823 filter_by_status: Support::Native,
1824 search_title: Support::Native,
1825 search_content: Support::Native,
1826 task_dependencies: DependencySupport::BothDirections,
1827 project_dependencies: DependencySupport::BothDirections,
1828 max_page_size: MAX_PAGE_SIZE,
1829 }
1830 }
1831 async fn health(&self) -> Result<Health, SourceError> {
1832 let board = self.board_page(None, 1).await?;
1833 Ok(Health {
1834 reachable: true,
1835 detail: Some(format!(
1836 "reading GitHub project {}/{} ({})",
1837 self.owner,
1838 self.project_number,
1839 required_str(&board, "title")?
1840 )),
1841 })
1842 }
1843 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1844 Ok(self
1845 .board()
1846 .await?
1847 .items
1848 .iter()
1849 .find(|item| item.id == *id && item.kind == ItemKind::Task)
1850 .map(Resolved::task))
1851 }
1852 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1853 Ok(self
1854 .board()
1855 .await?
1856 .items
1857 .iter()
1858 .find(|item| item.id == *id && item.kind == ItemKind::Project)
1859 .map(Resolved::project))
1860 }
1861 async fn query_tasks(
1862 &self,
1863 query: &TaskQuery,
1864 page: &PageRequest,
1865 ) -> Result<Page<Task>, SourceError> {
1866 validate_page(page)?;
1867 let tasks = self
1870 .board()
1871 .await?
1872 .items
1873 .iter()
1874 .filter(|item| item.kind == ItemKind::Task)
1875 .map(Resolved::task)
1876 .filter(|task| task_matches(task, query))
1877 .collect();
1878 Ok(offset_page(
1879 tasks,
1880 numeric_cursor(page.cursor.as_ref())?,
1881 page.limit.min(MAX_PAGE_SIZE) as usize,
1882 ))
1883 }
1884 async fn query_projects(
1885 &self,
1886 query: &ProjectQuery,
1887 page: &PageRequest,
1888 ) -> Result<Page<Project>, SourceError> {
1889 validate_page(page)?;
1890 let projects = self
1891 .board()
1892 .await?
1893 .items
1894 .iter()
1895 .filter(|item| item.kind == ItemKind::Project)
1896 .map(Resolved::project)
1897 .filter(|project| project_matches(project, query))
1898 .collect();
1899 Ok(offset_page(
1900 projects,
1901 numeric_cursor(page.cursor.as_ref())?,
1902 page.limit.min(MAX_PAGE_SIZE) as usize,
1903 ))
1904 }
1905 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1906 validate_page(page)?;
1907 let offset = numeric_cursor(page.cursor.as_ref())?;
1908 let mut labels = self
1909 .board()
1910 .await?
1911 .items
1912 .into_iter()
1913 .flat_map(|item| item.labels)
1914 .fold(Vec::new(), |mut all, label| {
1915 if !all.iter().any(|x: &Label| x.id == label.id) {
1916 all.push(label);
1917 }
1918 all
1919 });
1920 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
1921 Ok(offset_page(
1922 labels,
1923 offset,
1924 page.limit.min(MAX_PAGE_SIZE) as usize,
1925 ))
1926 }
1927 async fn task_dependencies(
1928 &self,
1929 id: &NativeId,
1930 direction: Direction,
1931 page: &PageRequest,
1932 ) -> Result<Page<DependencyEdge>, SourceError> {
1933 self.dependencies(id, ItemKind::Task, direction, page).await
1934 }
1935 async fn project_dependencies(
1936 &self,
1937 id: &NativeId,
1938 direction: Direction,
1939 page: &PageRequest,
1940 ) -> Result<Page<DependencyEdge>, SourceError> {
1941 self.dependencies(id, ItemKind::Project, direction, page)
1942 .await
1943 }
1944
1945 fn writes(&self) -> WriteSupport {
1946 WriteSupport::Supported
1947 }
1948
1949 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1950 self.write_item(
1951 &Incoming {
1952 kind: ItemKind::Task,
1953 title: &write.item.title,
1954 content: write.item.content.as_deref(),
1955 status: &write.item.status,
1956 labels: &write.item.labels,
1957 metadata: &write.item.metadata,
1958 repositories: &write.item.repositories,
1959 parent: write.item.project.as_ref(),
1960 },
1961 write.target.as_ref(),
1962 &write.depends_on,
1963 )
1964 .await
1965 }
1966
1967 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1968 self.write_item(
1969 &Incoming {
1970 kind: ItemKind::Project,
1971 title: &write.item.title,
1972 content: write.item.content.as_deref(),
1973 status: &write.item.status,
1974 labels: &write.item.labels,
1975 metadata: &write.item.metadata,
1976 repositories: &write.item.repositories,
1977 parent: None,
1978 },
1979 write.target.as_ref(),
1980 &write.depends_on,
1981 )
1982 .await
1983 }
1984}
1985
1986const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1989
1990const ORIGIN_FIELD: &str = "onetaskgraph.origin";
1995
1996const ORIGIN_KEY: &str = "onetaskgraph.origin";
2010
2011fn recorded_offset(
2019 cursor: Option<&str>,
2020 direction: Direction,
2021) -> Result<Option<usize>, SourceError> {
2022 cursor
2023 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
2024 .map(|offset| {
2025 if direction != Direction::DependsOn {
2026 return Err(SourceError::Config {
2027 message: format!(
2028 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
2029 reverse dependency read never issues; resume it in the direction \
2030 that reported it"
2031 ),
2032 });
2033 }
2034 offset.parse().map_err(|_| SourceError::Config {
2035 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
2036 })
2037 })
2038 .transpose()
2039}
2040
2041fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
2042 let mut page = offset_page(edges, offset, limit.max(1));
2043 page.next = page
2044 .next
2045 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
2046 page
2047}
2048
2049fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
2055 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
2056 if parent.is_some() {
2057 return Ok(ItemKind::Task);
2058 }
2059 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
2060 let id = required_str(value, "id")?;
2061 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
2062 message: format!("GitHub issue {id}: {message}"),
2063 })?;
2064 let sub_issues = sub_issue_total(value)?;
2065 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
2066 ItemKind::Project
2067 } else {
2068 ItemKind::Task
2069 })
2070}
2071
2072fn state_input(target: &StatusTarget) -> Value {
2079 match target {
2080 StatusTarget::Closed(reason) => json!({"value":"CLOSED","stateReason":reason.reason()}),
2081 StatusTarget::Column(_) | StatusTarget::Disabled => json!({"value":"OPEN"}),
2082 }
2083}
2084
2085fn slot_metadata(
2092 incoming: &Incoming<'_>,
2093 own_repository: Option<&Repository>,
2094 fallback: &[DependencyEdge],
2095) -> BTreeMap<String, Value> {
2096 let mut metadata = incoming.metadata.clone();
2097 metadata.remove(ORIGIN_KEY);
2098 metadata.insert(
2099 ItemKind::METADATA_KEY.to_owned(),
2100 Value::String(incoming.kind.marker().to_owned()),
2101 );
2102 let derivable = own_repository
2103 .map(|own| incoming.repositories == [own.clone()])
2104 .unwrap_or(incoming.repositories.is_empty());
2105 if derivable {
2106 metadata.remove(Repository::METADATA_KEY);
2107 } else {
2108 metadata.insert(
2109 Repository::METADATA_KEY.to_owned(),
2110 Value::Array(
2111 incoming
2112 .repositories
2113 .iter()
2114 .map(|repository| Value::String(repository.as_str().to_owned()))
2115 .collect(),
2116 ),
2117 );
2118 }
2119 if fallback.is_empty() {
2120 metadata.remove(DependencyEdge::RECORDED_KEY);
2121 } else {
2122 metadata.insert(
2123 DependencyEdge::RECORDED_KEY.to_owned(),
2124 Value::Array(
2125 fallback
2126 .iter()
2127 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
2128 .collect(),
2129 ),
2130 );
2131 }
2132 metadata
2133}
2134
2135fn labels(content: &Value, field_values: &[Value]) -> Result<Vec<Label>, SourceError> {
2136 let direct = optional_nodes(content.get("labels"), "content labels")?;
2137 let field = field_values
2138 .iter()
2139 .find_map(|value| value.get("labels"))
2140 .map(|labels| optional_nodes(Some(labels), "field labels"))
2141 .transpose()?
2142 .flatten();
2143 let labels = direct
2144 .into_iter()
2145 .flatten()
2146 .chain(field.into_iter().flatten())
2147 .map(|v| {
2148 Ok(Label {
2149 id: NativeId(required_str(v, "id")?.to_owned()),
2150 name: required_str(v, "name")?.to_owned(),
2151 color: optional_str(v, "color")?.map(str::to_owned),
2152 })
2153 })
2154 .collect::<Result<Vec<_>, SourceError>>()?
2155 .into_iter()
2156 .fold(Vec::new(), |mut labels, label| {
2157 if !labels.iter().any(|x: &Label| x.id == label.id) {
2158 labels.push(label);
2159 }
2160 labels
2161 });
2162 Ok(labels)
2163}
2164
2165fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
2166 let Some(node) = field_values
2167 .iter()
2168 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
2169 else {
2170 return Ok(None);
2171 };
2172 Ok(optional_str(node, "text")?.map(str::to_owned))
2173}
2174
2175fn valid_github_owner(owner: &str) -> bool {
2176 !owner.is_empty()
2177 && owner.len() <= 39
2178 && !owner.starts_with('-')
2179 && !owner.ends_with('-')
2180 && !owner.contains("--")
2181 && owner
2182 .bytes()
2183 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2184}
2185
2186fn valid_github_repository_name(name: &str) -> bool {
2189 !name.is_empty()
2190 && name.len() <= 100
2191 && name != "."
2192 && name != ".."
2193 && name
2194 .bytes()
2195 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2196}
2197
2198fn valid_environment_name(name: &str) -> bool {
2199 let mut bytes = name.bytes();
2200 bytes
2201 .next()
2202 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2203 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2204}
2205
2206fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
2213 let summary = issue
2214 .get("subIssuesSummary")
2215 .ok_or_else(|| SourceError::Malformed {
2216 message: "GitHub issue is missing subIssuesSummary".into(),
2217 })?;
2218 summary
2219 .get("total")
2220 .and_then(Value::as_u64)
2221 .ok_or_else(|| SourceError::Malformed {
2222 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
2223 })
2224}
2225
2226fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2227 value
2228 .get(field)
2229 .and_then(Value::as_str)
2230 .ok_or_else(|| SourceError::Malformed {
2231 message: format!("GitHub response is missing string field {field}"),
2232 })
2233}
2234
2235const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2243const METADATA_CLOSE: &str = "\n-->";
2244
2245fn metadata_body(
2251 body: Option<String>,
2252) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
2253 let Some(body) = body else {
2254 return Ok((None, BTreeMap::new()));
2255 };
2256 let Some(start) = body.rfind(METADATA_OPEN) else {
2257 return Ok((Some(body), BTreeMap::new()));
2258 };
2259 let encoded_start = start + METADATA_OPEN.len();
2260 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
2261 return Err(SourceError::Malformed {
2262 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
2263 });
2264 };
2265 let encoded_end = encoded_start + relative_end;
2266 if !body[encoded_end + METADATA_CLOSE.len()..].trim().is_empty() {
2267 return Ok((Some(body), BTreeMap::new()));
2268 }
2269 let metadata = serde_json::from_str(&body[encoded_start..encoded_end]).map_err(|error| {
2270 SourceError::Malformed {
2271 message: format!(
2272 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
2273 ),
2274 }
2275 })?;
2276 let visible = body[..start].trim_end();
2277 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2278}
2279
2280fn compose_body(
2281 content: Option<&str>,
2282 metadata: &BTreeMap<String, Value>,
2283) -> Result<Option<String>, SourceError> {
2284 let visible = content.unwrap_or_default();
2285 if metadata.is_empty() {
2286 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2287 }
2288 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
2289 message: error.to_string(),
2290 })?;
2291 Ok(Some(if visible.is_empty() {
2292 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2293 } else {
2294 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2295 }))
2296}
2297
2298fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
2299 value
2300 .get(field)
2301 .and_then(Value::as_bool)
2302 .ok_or_else(|| SourceError::Malformed {
2303 message: format!("GitHub response is missing boolean field {field}"),
2304 })
2305}
2306fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
2307 match value.get(field) {
2308 None | Some(Value::Null) => Ok(None),
2309 Some(value) => value
2310 .as_str()
2311 .map(Some)
2312 .ok_or_else(|| SourceError::Malformed {
2313 message: format!("GitHub response field {field} is not a string or null"),
2314 }),
2315 }
2316}
2317fn optional_nodes<'a>(
2318 connection: Option<&'a Value>,
2319 name: &str,
2320) -> Result<Option<&'a Vec<Value>>, SourceError> {
2321 match connection {
2322 None | Some(Value::Null) => Ok(None),
2323 Some(value) => value
2324 .get("nodes")
2325 .and_then(Value::as_array)
2326 .map(Some)
2327 .ok_or_else(|| SourceError::Malformed {
2328 message: format!("GitHub {name}.nodes is not an array"),
2329 }),
2330 }
2331}
2332fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
2333 let page_info = connection
2334 .get("pageInfo")
2335 .ok_or_else(|| SourceError::Malformed {
2336 message: format!("GitHub {name} has no pageInfo"),
2337 })?;
2338 if required_bool(page_info, "hasNextPage")? {
2339 return Err(SourceError::Malformed {
2340 message: format!(
2341 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
2342 ),
2343 });
2344 }
2345 Ok(())
2346}
2347fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2348 optional_str(value, field)?
2349 .map(|timestamp| {
2350 timestamp.parse().map_err(|error| SourceError::Malformed {
2351 message: format!("GitHub response field {field} is not a timestamp: {error}"),
2352 })
2353 })
2354 .transpose()
2355}
2356fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
2357 if page.limit == 0 {
2358 Err(SourceError::Config {
2359 message: "page limit must be at least 1".into(),
2360 })
2361 } else {
2362 Ok(())
2363 }
2364}
2365fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
2366 let page = connection
2367 .get("pageInfo")
2368 .filter(|value| value.is_object())
2369 .ok_or_else(|| SourceError::Malformed {
2370 message: "GitHub connection is missing pageInfo".into(),
2371 })?;
2372 if required_bool(page, "hasNextPage")? {
2373 let cursor = required_str(page, "endCursor")?;
2374 validate_cursor_progress(None, cursor)?;
2375 Ok(Some(Cursor(cursor.into())))
2376 } else {
2377 Ok(None)
2378 }
2379}
2380fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
2381 if next.is_empty() || previous == Some(next) {
2382 Err(SourceError::Malformed {
2383 message: "GitHub pagination cursor is empty or did not advance".into(),
2384 })
2385 } else {
2386 Ok(())
2387 }
2388}
2389fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
2390 cursor.map_or(Ok(0), |c| {
2391 c.0.parse().map_err(|_| SourceError::Config {
2392 message: "page cursor is invalid".into(),
2393 })
2394 })
2395}
2396fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
2397 if offset > items.len() {
2398 return Page::last(vec![]);
2399 }
2400 let tail = items.split_off(offset);
2401 let mut selected = tail;
2402 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
2403 selected.truncate(limit);
2404 Page {
2405 items: selected,
2406 next,
2407 }
2408}