1#![deny(missing_docs)]
56
57use std::collections::BTreeMap;
58
59use chrono::{DateTime, Utc};
60use onetaskgraph_plugin_api::{
61 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
62 Direction, Health, ItemKind, ItemWrite, Label, NativeId, Page, PageRequest, Project,
63 ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin, Status,
64 StatusCategory, Support, Task, TaskQuery, TaskSource, WriteSupport,
65};
66use reqwest::{Client, StatusCode, Url};
67use schemars::{Schema, schema_for};
68use secrecy::{ExposeSecret, SecretString};
69use serde::Deserialize;
70use serde_json::{Value, json};
71
72pub const KIND: &str = "github-projects";
74pub const MAX_PAGE_SIZE: u32 = 100;
76const NESTED_PAGE_SIZE: u32 = 50;
78
79pub mod graphql {
86 pub const BOARD: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
88 owner:repositoryOwner(login:$owner){
89 ... on ProjectV2Owner{projectV2(number:$number){...Board}}
90 }
91 } fragment Board on ProjectV2 { id title
92 fields(first:$nestedFirst){nodes{
93 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
94 ... on ProjectV2Field{__typename id name}
95 }pageInfo{hasNextPage}}
96 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
97 ... on ProjectV2ItemFieldSingleSelectValue{name field{
98 ... on ProjectV2SingleSelectField{id name options{id name}}
99 }}
100 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
101 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
102 }pageInfo{hasNextPage}} content{
103 ... 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}}}
104 ... on PullRequest{__typename id}
105 ... on DraftIssue{__typename id title body createdAt updatedAt}
106 }} pageInfo{hasNextPage endCursor}}
107 }"#;
108 pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
110 pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
112 ... on Issue{
113 blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
114 blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
115 }}} fragment Related on Issue{id body parent{id} subIssuesSummary{total}}"#;
116 pub const CREATE_ISSUE: &str =
118 r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id}}}"#;
119 pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
121 pub const UPDATE_ISSUE: &str =
123 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
124 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
126 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
128 pub const ADD_SUB_ISSUE: &str =
130 r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
131 pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
133 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
135 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
137}
138
139fn default_token_env() -> String {
140 "GH_PROJECTS_TOKEN".to_owned()
141}
142fn default_endpoint() -> String {
143 "https://api.github.com/graphql".to_owned()
144}
145
146#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
151#[serde(untagged)]
152pub enum StatusTargetConfig {
153 Column(ColumnName),
155 Closed {
157 closed: ClosedState,
159 },
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
167#[serde(try_from = "String")]
168pub struct ColumnName(String);
169
170impl ColumnName {
171 fn as_str(&self) -> &str {
173 &self.0
174 }
175}
176
177impl TryFrom<String> for ColumnName {
178 type Error = String;
179
180 fn try_from(name: String) -> Result<Self, Self::Error> {
181 if name.trim().is_empty() {
182 return Err("a status_mapping option name cannot be blank".to_owned());
183 }
184 Ok(Self(name))
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "kebab-case")]
194pub enum ClosedState {
195 Completed,
197 NotPlanned,
199}
200
201impl ClosedState {
202 const fn reason(self) -> &'static str {
203 match self {
204 Self::Completed => "COMPLETED",
205 Self::NotPlanned => "NOT_PLANNED",
206 }
207 }
208}
209
210#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
212#[serde(default, deny_unknown_fields)]
213pub struct GitHubProjectsConfig {
214 pub owner: String, pub project_number: u32, pub repository: Option<String>, #[serde(default = "default_token_env")]
227 pub token_env: String, #[serde(default = "default_endpoint")]
230 pub endpoint: String, #[serde(default)]
238 pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, }
240
241#[derive(Debug, Clone, Copy, Default)]
243pub struct Plugin;
244
245impl SourcePlugin for Plugin {
246 fn kind(&self) -> &'static str {
247 KIND
248 }
249 fn config_schema(&self) -> Schema {
250 schema_for!(GitHubProjectsConfig)
251 }
252 fn build(
253 &self,
254 name: &SourceName,
255 config: &Value,
256 secrets: &dyn SecretResolver,
257 ) -> Result<Box<dyn TaskSource>, SourceError> {
258 let config: GitHubProjectsConfig =
259 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
260 message: format!("source {name}: {e}"),
261 })?;
262 let source =
263 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
264 SourceError::Config { message } => SourceError::Config {
265 message: format!("source {name}: {message}"),
266 },
267 SourceError::Auth { message } => SourceError::Auth {
268 message: format!("source {name}: {message}"),
269 },
270 other => other,
271 })?;
272 Ok(Box::new(source))
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278enum StatusTarget {
279 Disabled,
281 Column(ColumnName),
283 Closed(ClosedState),
285}
286
287pub const CATEGORIES: [StatusCategory; 7] = [
295 StatusCategory::Draft,
296 StatusCategory::Backlog,
297 StatusCategory::Todo,
298 StatusCategory::InProgress,
299 StatusCategory::Done,
300 StatusCategory::Cancelled,
301 StatusCategory::Unknown,
302];
303
304#[must_use]
306pub const fn category_position(category: StatusCategory) -> usize {
307 match category {
308 StatusCategory::Draft => 0,
309 StatusCategory::Backlog => 1,
310 StatusCategory::Todo => 2,
311 StatusCategory::InProgress => 3,
312 StatusCategory::Done => 4,
313 StatusCategory::Cancelled => 5,
314 StatusCategory::Unknown => 6,
315 }
316}
317
318fn category_name(category: StatusCategory) -> &'static str {
320 match category {
321 StatusCategory::Draft => "draft",
322 StatusCategory::Backlog => "backlog",
323 StatusCategory::Todo => "todo",
324 StatusCategory::InProgress => "in-progress",
325 StatusCategory::Done => "done",
326 StatusCategory::Cancelled => "cancelled",
327 StatusCategory::Unknown => "unknown",
328 }
329}
330
331fn shipped_column(name: &'static str) -> ColumnName {
336 ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
337}
338
339fn shipped_default(category: StatusCategory) -> StatusTarget {
341 match category {
342 StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
343 StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
344 StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
345 StatusCategory::Done => StatusTarget::Closed(ClosedState::Completed),
346 StatusCategory::Cancelled => StatusTarget::Closed(ClosedState::NotPlanned),
347 StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
348 }
349}
350
351#[derive(Debug, Clone)]
357struct StatusMapping {
358 targets: [StatusTarget; CATEGORIES.len()],
359}
360
361impl StatusMapping {
362 fn resolve(
363 configured: BTreeMap<String, Option<StatusTargetConfig>>,
364 instance: &SourceName,
365 ) -> Result<Self, SourceError> {
366 let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
367 for (key, value) in configured {
368 let category = CATEGORIES
369 .iter()
370 .find(|category| category_name(**category) == key)
371 .ok_or_else(|| SourceError::Config {
372 message: format!(
373 "status_mapping names {key:?}, which is not a status category of source \
374 {instance}; the categories are {}",
375 CATEGORIES
376 .iter()
377 .map(|category| category_name(*category))
378 .collect::<Vec<_>>()
379 .join(", ")
380 ),
381 })?;
382 overrides.insert(category_name(*category), value);
383 }
384 let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
387 None => shipped_default(category),
388 Some(None) => StatusTarget::Disabled,
389 Some(Some(StatusTargetConfig::Column(option))) => StatusTarget::Column(option),
390 Some(Some(StatusTargetConfig::Closed { closed })) => StatusTarget::Closed(closed),
391 });
392 let mapping = Self { targets };
393 for (index, category) in CATEGORIES.into_iter().enumerate() {
394 let StatusTarget::Column(option) = mapping.target(category) else {
395 continue;
396 };
397 if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
398 matches!(mapping.target(**earlier), StatusTarget::Column(name)
399 if name.as_str().eq_ignore_ascii_case(option.as_str()))
400 }) {
401 return Err(SourceError::Config {
402 message: format!(
403 "status_mapping of source {instance} sends both {} and {} to the board \
404 option {:?}; one option cannot read back as two categories",
405 category_name(*other),
406 category_name(category),
407 option.as_str()
408 ),
409 });
410 }
411 }
412 Ok(mapping)
413 }
414
415 fn target(&self, category: StatusCategory) -> &StatusTarget {
416 &self.targets[category_position(category)]
417 }
418
419 fn category_of(&self, option: &str) -> Option<StatusCategory> {
421 CATEGORIES.into_iter().find(|category| {
422 matches!(self.target(*category), StatusTarget::Column(name)
423 if name.as_str().eq_ignore_ascii_case(option))
424 })
425 }
426}
427
428#[derive(Debug, Clone)]
430struct RepositoryTarget {
431 owner: String, name: String, }
434
435impl RepositoryTarget {
436 fn parse(value: &str) -> Result<Self, SourceError> {
437 let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
438 message: format!(
439 "repository must be spelled owner/name; {value:?} names no repository"
440 ),
441 })?;
442 if !valid_github_owner(owner) || !valid_github_repository_name(name) {
443 return Err(SourceError::Config {
444 message: format!(
445 "repository must be spelled owner/name with a GitHub login and one \
446 repository name; {value:?} is not"
447 ),
448 });
449 }
450 Ok(Self {
451 owner: owner.to_owned(),
452 name: name.to_owned(),
453 })
454 }
455
456 fn origin(&self) -> String {
457 format!("github.com/{}/{}", self.owner, self.name)
458 }
459}
460
461pub struct GitHubProjectsSource {
463 name: SourceName,
467 owner: String, project_number: u32, repository: Option<RepositoryTarget>,
470 endpoint: Url,
471 token: SecretString,
472 credential_name: String, statuses: StatusMapping,
474 client: Client,
475}
476
477impl GitHubProjectsSource {
478 pub fn new(
485 name: &SourceName,
486 config: GitHubProjectsConfig,
487 secrets: &dyn SecretResolver,
488 ) -> Result<Self, SourceError> {
489 if !valid_github_owner(&config.owner) {
490 return Err(SourceError::Config {
491 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
492 });
493 }
494 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
495 return Err(SourceError::Config {
496 message: format!("project_number must be between 1 and {}", i32::MAX),
497 });
498 }
499 if !valid_environment_name(&config.token_env) {
500 return Err(SourceError::Config {
501 message: "token_env must be a valid environment-variable name".into(),
502 });
503 }
504 let repository = config
505 .repository
506 .as_deref()
507 .map(RepositoryTarget::parse)
508 .transpose()?;
509 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
510 message: format!("endpoint is not a valid URL: {e}"),
511 })?;
512 if endpoint.scheme() != "https"
513 && !(endpoint.scheme() == "http"
514 && endpoint
515 .host_str()
516 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
517 {
518 return Err(SourceError::Config {
519 message:
520 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
521 .into(),
522 });
523 }
524 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
525 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),
526 })?;
527 Ok(Self {
528 name: name.clone(),
529 owner: config.owner,
530 project_number: config.project_number,
531 repository,
532 endpoint,
533 token,
534 credential_name: config.token_env,
535 statuses: StatusMapping::resolve(config.status_mapping, name)?,
536 client: Client::builder()
537 .user_agent("onetaskgraph")
538 .build()
539 .map_err(|e| SourceError::Config {
540 message: format!("cannot build HTTP client: {e}"),
541 })?,
542 })
543 }
544
545 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
546 let response = self
547 .client
548 .post(self.endpoint.clone())
549 .bearer_auth(self.token.expose_secret())
550 .json(&json!({"query": query, "variables": variables}))
551 .send()
552 .await
553 .map_err(|e| SourceError::Unavailable {
554 message: format!("GitHub GraphQL request failed: {e}"),
555 })?;
556 let status = response.status();
557 let retry_after = response
558 .headers()
559 .get("retry-after")
560 .and_then(|v| v.to_str().ok())
561 .and_then(|v| v.parse().ok());
562 let exhausted = response
563 .headers()
564 .get("x-ratelimit-remaining")
565 .and_then(|v| v.to_str().ok())
566 == Some("0");
567 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
568 return Err(SourceError::RateLimited {
569 retry_after_seconds: retry_after,
570 });
571 }
572 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
573 return Err(SourceError::Auth {
574 message: format!(
575 "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"
576 ),
577 });
578 }
579 if !status.is_success() {
580 return Err(SourceError::Unavailable {
581 message: format!("GitHub GraphQL returned HTTP {status}"),
582 });
583 }
584 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
585 message: format!("GitHub returned invalid JSON: {e}"),
586 })?;
587 let errors = body
588 .get("errors")
589 .map(|value| {
590 value.as_array().ok_or_else(|| SourceError::Malformed {
591 message: "GitHub response errors is not an array".into(),
592 })
593 })
594 .transpose()?;
595 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
596 let messages = errors
597 .iter()
598 .filter_map(|e| e.get("message").and_then(Value::as_str))
599 .collect::<Vec<_>>()
600 .join("; ");
601 let message = if messages.is_empty() {
602 "GitHub returned GraphQL errors".into()
603 } else {
604 messages
605 };
606 let normalized = message.to_ascii_lowercase();
607 if normalized.contains("resource not accessible") || normalized.contains("scope") {
608 return Err(SourceError::Auth {
609 message: format!(
610 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
611 self.credential_name
612 ),
613 });
614 }
615 return Err(SourceError::Refused { message });
616 }
617 body.get("data")
618 .filter(|data| data.is_object())
619 .cloned()
620 .ok_or_else(|| SourceError::Malformed {
621 message: "GitHub response has no data object".into(),
622 })
623 }
624
625 async fn board_page(
629 &self,
630 items_after: Option<&str>,
631 items_first: u32,
632 ) -> Result<Value, SourceError> {
633 let data = self
634 .graphql(
635 graphql::BOARD,
636 json!({"owner":self.owner,"number":self.project_number,
637 "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
638 "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
639 )
640 .await?;
641 data.pointer("/owner/projectV2")
642 .filter(|v| !v.is_null())
643 .cloned()
644 .ok_or_else(|| SourceError::Refused {
645 message: format!(
646 "GitHub project {}/{} was not found or is not visible to the token",
647 self.owner, self.project_number
648 ),
649 })
650 }
651
652 async fn board(&self) -> Result<Board, SourceError> {
654 let mut after: Option<String> = None;
655 let mut items = Vec::new();
656 let mut board;
657 loop {
658 let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
659 for item in page
660 .pointer("/items/nodes")
661 .and_then(Value::as_array)
662 .ok_or_else(|| SourceError::Malformed {
663 message: "GitHub project items.nodes is not an array".into(),
664 })?
665 {
666 if let Some(resolved) = self.resolve(item)? {
667 items.push(resolved);
668 }
669 }
670 let info = page
671 .pointer("/items/pageInfo")
672 .ok_or_else(|| SourceError::Malformed {
673 message: "GitHub project items have no pageInfo".into(),
674 })?;
675 let has_next = required_bool(info, "hasNextPage")?;
676 let next = has_next
677 .then(|| required_str(info, "endCursor"))
678 .transpose()?;
679 board = page.clone();
680 match next {
681 Some(next) => {
682 validate_cursor_progress(after.as_deref(), next)?;
683 after = Some(next.to_owned());
684 }
685 None => break,
686 }
687 }
688 Ok(Board {
689 id: required_str(&board, "id")?.to_owned(),
690 fields: board.get("fields").cloned().unwrap_or(Value::Null),
691 items,
692 })
693 }
694
695 fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
701 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
702 message: "GitHub project item is missing content".into(),
703 })?;
704 if content.is_null() {
705 return Ok(None);
706 }
707 let content_kind = match required_str(content, "__typename")? {
708 "Issue" => ContentKind::Issue,
709 "DraftIssue" => ContentKind::DraftIssue,
710 _ => return Ok(None),
711 };
712 let field_values = item
713 .get("fieldValues")
714 .ok_or_else(|| SourceError::Malformed {
715 message: "GitHub project item is missing fieldValues".into(),
716 })?;
717 complete_connection(field_values, "project item field values")?;
718 let nodes = field_values
719 .get("nodes")
720 .and_then(Value::as_array)
721 .ok_or_else(|| SourceError::Malformed {
722 message: "GitHub project item fieldValues.nodes is not an array".into(),
723 })?;
724 if let Some(labels) = content.get("labels") {
725 complete_connection(labels, "content labels")?;
726 }
727 for field_value in nodes {
728 if let Some(labels) = field_value.get("labels") {
729 complete_connection(labels, "project item field labels")?;
730 }
731 }
732 let (body, slot) = metadata_body(optional_str(content, "body")?.map(str::to_owned))?;
733 let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
734 .map(|id| NativeId(id.to_owned()));
735 let sub_issues = match content_kind {
738 ContentKind::Issue => sub_issue_total(content)?,
739 ContentKind::DraftIssue => 0,
740 };
741 let content_id = required_str(content, "id")?;
742 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
743 message: format!("GitHub issue {content_id}: {message}"),
744 })?;
745 let kind = if parent.is_some() {
748 ItemKind::Task
749 } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
750 ItemKind::Project
751 } else {
752 ItemKind::Task
753 };
754 let own_repository = content
755 .pointer("/repository/nameWithOwner")
756 .and_then(Value::as_str)
757 .map(|origin| Repository::try_from(format!("github.com/{origin}")))
758 .transpose()
759 .map_err(|message| SourceError::Malformed { message })?;
760 let repositories = if slot.contains_key(Repository::METADATA_KEY) {
761 Repository::from_metadata(&slot)
762 .map_err(|message| SourceError::Malformed { message })?
763 } else {
764 own_repository.clone().into_iter().collect()
765 };
766 Ok(Some(Resolved {
767 item_id: required_str(item, "id")?.to_owned(),
768 id: NativeId(content_id.to_owned()),
769 content_kind,
770 kind,
771 title: required_str(content, "title")?.to_owned(),
772 body: body.filter(|value| !value.is_empty()),
773 status: self.status(item, content)?,
774 labels: labels(content, nodes)?,
775 parent,
776 origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
777 url: optional_str(content, "url")?.map(str::to_owned),
778 created_at: optional_time(content, "createdAt")?,
779 updated_at: optional_time(content, "updatedAt")?,
780 own_repository,
781 repositories,
782 slot,
783 }))
784 }
785
786 fn status(&self, item: &Value, content: &Value) -> Result<Status, SourceError> {
796 let nodes = item
797 .pointer("/fieldValues/nodes")
798 .and_then(Value::as_array)
799 .expect("resolve validates fieldValues.nodes before mapping status");
800 let option = nodes
801 .iter()
802 .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
803 .map(|value| required_str(value, "name"))
804 .transpose()?;
805 let state = optional_str(content, "state")?;
806 if state == Some("CLOSED") {
807 let category = match optional_str(content, "stateReason")? {
808 None | Some("COMPLETED") => StatusCategory::Done,
809 Some("NOT_PLANNED") => StatusCategory::Cancelled,
810 Some(_) => StatusCategory::Unknown,
811 };
812 let fallback = match category {
813 StatusCategory::Done => "Done",
814 StatusCategory::Cancelled => "Cancelled",
815 _ => "Closed",
816 };
817 return Ok(Status {
818 category,
819 name: option.unwrap_or(fallback).to_owned(),
820 });
821 }
822 let name = option.unwrap_or("Open").to_owned();
823 Ok(Status {
824 category: self
825 .statuses
826 .category_of(&name)
827 .unwrap_or(StatusCategory::Unknown),
828 name,
829 })
830 }
831
832 fn column_for(
840 &self,
841 board: &Board,
842 status: &Status,
843 target: &StatusTarget,
844 ) -> Result<Option<(String, String)>, SourceError> {
845 let (wanted, required) = match target {
846 StatusTarget::Column(wanted) => (wanted.as_str(), true),
847 StatusTarget::Closed(_) => (status.name.as_str(), false),
848 StatusTarget::Disabled => return Ok(None),
849 };
850 let missing = |detail: &str| SourceError::Refused {
851 message: format!(
852 "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",
853 category_name(status.category),
854 self.name,
855 category_name(status.category)
856 ),
857 };
858 let Some(field) = Board::field(&board.fields, "Status")? else {
859 return if required {
860 Err(missing("this board has no Status field"))
861 } else {
862 Ok(None)
863 };
864 };
865 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
866 return if required {
867 Err(missing(
868 "this board's Status field is not a single-select field",
869 ))
870 } else {
871 Ok(None)
872 };
873 }
874 let option = field
875 .get("options")
876 .and_then(Value::as_array)
877 .and_then(|options| {
878 options.iter().find(|option| {
879 option
880 .get("name")
881 .and_then(Value::as_str)
882 .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
883 })
884 });
885 match option {
886 None if required => Err(missing("this board does not have it")),
887 None => Ok(None),
888 Some(option) => Ok(Some((
889 required_str(field, "id")?.to_owned(),
890 required_str(option, "id")?.to_owned(),
891 ))),
892 }
893 }
894
895 fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
902 let target = self.statuses.target(category).clone();
903 if target != StatusTarget::Disabled {
904 return Ok(target);
905 }
906 Err(SourceError::Refused {
907 message: if category == StatusCategory::Draft {
908 format!(
909 "status draft is disabled for source {}: draft is incompatible with this \
910 integration because GitHub draft issues cannot have sub-issues, and this \
911 source stores a project's tasks as its issue's sub-issues",
912 self.name
913 )
914 } else {
915 format!(
916 "status {} is disabled for source {}; set status_mapping.{} of this source \
917 to a board Status option name or to a closed state",
918 category_name(category),
919 self.name,
920 category_name(category)
921 )
922 },
923 })
924 }
925
926 async fn set_item_field(
927 &self,
928 board_id: &str,
929 item_id: &str,
930 field_id: &str,
931 value: Value,
932 ) -> Result<(), SourceError> {
933 let data = self
934 .graphql(
935 graphql::UPDATE_FIELD,
936 json!({"input":{
937 "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
938 }}),
939 )
940 .await?;
941 let returned = data
942 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
943 .ok_or_else(|| SourceError::Malformed {
944 message: "GitHub field update returned no project item".into(),
945 })?;
946 if required_str(returned, "id")? != item_id {
947 return Err(SourceError::Malformed {
948 message: "GitHub field update returned the wrong project item".into(),
949 });
950 }
951 Ok(())
952 }
953
954 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
955 let mut after: Option<String> = None;
956 let mut ids = Vec::new();
957 loop {
958 let data = self
959 .graphql(
960 graphql::ISSUE_DEPENDENCIES,
961 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
962 )
963 .await?;
964 let connection =
965 data.pointer("/node/blockedBy")
966 .ok_or_else(|| SourceError::Malformed {
967 message: "GitHub dependency response has no blockedBy connection".into(),
968 })?;
969 ids.extend(
970 connection
971 .get("nodes")
972 .and_then(Value::as_array)
973 .ok_or_else(|| SourceError::Malformed {
974 message: "GitHub dependency response nodes is not an array".into(),
975 })?
976 .iter()
977 .map(|value| required_str(value, "id").map(str::to_owned))
978 .collect::<Result<Vec<_>, _>>()?,
979 );
980 let next = next_cursor(connection)?;
981 if let Some(next) = &next {
982 validate_cursor_progress(after.as_deref(), &next.0)?;
983 }
984 after = next.map(|cursor| cursor.0);
985 if after.is_none() {
986 return Ok(ids);
987 }
988 }
989 }
990
991 async fn dependencies(
992 &self,
993 id: &NativeId,
994 near_kind: ItemKind,
995 direction: Direction,
996 page: &PageRequest,
997 ) -> Result<Page<DependencyEdge>, SourceError> {
998 validate_page(page)?;
999 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
1000 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1001 let recorded = recorded_offset(cursor, direction)?;
1002 let data = self
1007 .graphql(
1008 graphql::ISSUE_DEPENDENCIES,
1009 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
1010 "after":if recorded.is_some() {None} else {cursor}}),
1011 )
1012 .await?;
1013 let node =
1014 data.get("node")
1015 .filter(|v| !v.is_null())
1016 .ok_or_else(|| SourceError::Refused {
1017 message: format!(
1018 "GitHub item {} was not found or does not support dependencies",
1019 id.0
1020 ),
1021 })?;
1022 let connection_name = match direction {
1023 Direction::DependsOn => "blockedBy",
1024 Direction::DependedOnBy => "blocking",
1025 };
1026 let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
1030 if let Some(offset) = recorded {
1031 return Ok(recorded_page(
1032 self.recorded_edges(id, near_kind, direction, natively_names)
1033 .await?,
1034 offset,
1035 limit,
1036 ));
1037 }
1038 if natively_names.is_none() {
1039 return Ok(recorded_page(
1040 self.recorded_edges(id, near_kind, direction, natively_names)
1041 .await?,
1042 0,
1043 limit,
1044 ));
1045 }
1046 let connection = node
1047 .get(connection_name)
1048 .ok_or_else(|| SourceError::Malformed {
1049 message: "GitHub dependency response is missing its connection".into(),
1050 })?;
1051 let nodes = connection
1052 .get("nodes")
1053 .and_then(Value::as_array)
1054 .ok_or_else(|| SourceError::Malformed {
1055 message: "GitHub dependency response nodes is not an array".into(),
1056 })?;
1057 let items = nodes
1061 .iter()
1062 .map(|value| {
1063 let related = NativeId(required_str(value, "id")?.into());
1064 let related_kind = related_kind(value)?;
1065 let (from, to) = match direction {
1066 Direction::DependsOn => (
1067 DependencyEndpoint::from_native(id.clone(), near_kind),
1068 DependencyEndpoint::from_native(related, related_kind),
1069 ),
1070 Direction::DependedOnBy => (
1071 DependencyEndpoint::from_native(related, related_kind),
1072 DependencyEndpoint::from_native(id.clone(), near_kind),
1073 ),
1074 };
1075 Ok(DependencyEdge {
1076 from,
1077 to,
1078 kind: DependencyKind::Blocks,
1079 })
1080 })
1081 .collect::<Result<Vec<_>, SourceError>>()?;
1082 let mut next = next_cursor(connection)?;
1083 if let Some(next) = &next {
1084 validate_cursor_progress(cursor, &next.0)?;
1085 }
1086 if next.is_none()
1087 && !self
1088 .recorded_edges(id, near_kind, direction, natively_names)
1089 .await?
1090 .is_empty()
1091 {
1092 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1093 }
1094 Ok(Page { items, next })
1095 }
1096
1097 async fn recorded_edges(
1107 &self,
1108 id: &NativeId,
1109 near_kind: ItemKind,
1110 direction: Direction,
1111 natively_names: Option<ItemKind>,
1112 ) -> Result<Vec<DependencyEdge>, SourceError> {
1113 if direction != Direction::DependsOn {
1114 return Ok(Vec::new());
1115 }
1116 let Some(item) = self
1117 .board()
1118 .await?
1119 .items
1120 .into_iter()
1121 .find(|item| item.id == *id)
1122 else {
1123 return Ok(Vec::new());
1124 };
1125 DependencyEdge::recorded(&item.slot, id, near_kind, &self.name, natively_names)
1126 .map_err(|message| SourceError::Malformed { message })
1127 }
1128
1129 async fn repository_id(&self) -> Result<String, SourceError> {
1131 let repository = self
1132 .repository
1133 .as_ref()
1134 .ok_or_else(|| SourceError::Refused {
1135 message: format!(
1136 "source {} has no repository configured, and a GitHub Projects board has no \
1137 repository of its own to create an issue in; set repository: owner/name on \
1138 this source",
1139 self.name
1140 ),
1141 })?;
1142 let data = self
1143 .graphql(
1144 graphql::REPOSITORY,
1145 json!({"owner":repository.owner,"name":repository.name}),
1146 )
1147 .await?;
1148 let node = data
1149 .get("repository")
1150 .filter(|value| !value.is_null())
1151 .ok_or_else(|| SourceError::Refused {
1152 message: format!(
1153 "GitHub repository {}/{} was not found or is not visible to the token",
1154 repository.owner, repository.name
1155 ),
1156 })?;
1157 Ok(required_str(node, "id")?.to_owned())
1158 }
1159
1160 async fn write_item(
1162 &self,
1163 incoming: &Incoming<'_>,
1164 target: Option<&NativeId>,
1165 depends_on: &[DependencyEdge],
1166 ) -> Result<NativeId, SourceError> {
1167 let board = self.board().await?;
1168 let status_target = self.resolved_target(incoming.status.category)?;
1169 let column = self.column_for(&board, incoming.status, &status_target)?;
1170 let existing = target
1171 .map(|target| {
1172 board
1173 .items
1174 .iter()
1175 .find(|item| item.id == *target)
1176 .ok_or_else(|| SourceError::Refused {
1177 message: format!("GitHub destination item {} was not found", target.0),
1178 })
1179 })
1180 .transpose()?;
1181 let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
1182 if content_kind == ContentKind::DraftIssue {
1183 if let StatusTarget::Closed(_) = status_target {
1184 return Err(SourceError::Refused {
1185 message: format!(
1186 "status {} of source {} closes the item's issue, and GitHub draft items \
1187 have no open or closed state",
1188 category_name(incoming.status.category),
1189 self.name
1190 ),
1191 });
1192 }
1193 if incoming.parent.is_some() {
1194 return Err(SourceError::Refused {
1195 message: "GitHub draft items cannot be a project's sub-issue".into(),
1196 });
1197 }
1198 }
1199 match existing {
1200 Some(item) if content_kind == ContentKind::Issue => {
1201 if item.labels != incoming.labels {
1202 return Err(SourceError::Refused {
1203 message: "GitHub issue labels differ from the labels being written".into(),
1204 });
1205 }
1206 }
1207 _ => {
1208 if !incoming.labels.is_empty() {
1209 return Err(SourceError::Refused {
1210 message: "GitHub items created by this destination carry no labels".into(),
1211 });
1212 }
1213 }
1214 }
1215
1216 let own_repository = match existing {
1217 Some(item) => item.own_repository.clone(),
1218 None => self
1219 .repository
1220 .as_ref()
1221 .map(|repository| Repository::try_from(repository.origin()))
1222 .transpose()
1223 .map_err(|message| SourceError::Config { message })?,
1224 };
1225 let (native, fallback) = self
1226 .partition_edges(&board, incoming.kind, content_kind, depends_on)
1227 .await?;
1228 let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
1229 let body = compose_body(incoming.content, &slot)?;
1230 let origin = match incoming.metadata.get(ORIGIN_KEY) {
1237 None => "",
1238 Some(Value::String(origin)) => origin.as_str(),
1239 Some(other) => {
1240 return Err(SourceError::Refused {
1241 message: format!(
1242 "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
1243 is {other}"
1244 ),
1245 });
1246 }
1247 };
1248 let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
1252 Some(field) => {
1253 if required_str(field, "__typename")? != "ProjectV2Field" {
1254 return Err(SourceError::Refused {
1255 message: format!(
1256 "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
1257 ),
1258 });
1259 }
1260 Some(required_str(field, "id")?.to_owned())
1261 }
1262 None if incoming.metadata.contains_key(ORIGIN_KEY) => {
1263 return Err(SourceError::Refused {
1264 message: format!(
1265 "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
1266 item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
1267 the board"
1268 ),
1269 });
1270 }
1271 None => None,
1272 };
1273
1274 let (content_id, item_id) = match existing {
1275 Some(item) => {
1276 self.update_existing(item, incoming, &body, &status_target)
1277 .await?;
1278 (item.id.clone(), item.item_id.clone())
1279 }
1280 None => {
1281 self.create_and_file_issue(&board, incoming, &body, &status_target)
1282 .await?
1283 }
1284 };
1285
1286 if let Some(field_id) = &origin_field {
1287 self.set_item_field(&board.id, &item_id, field_id, json!({"text":origin}))
1288 .await?;
1289 }
1290
1291 if let Some((field_id, option_id)) = column {
1292 self.set_item_field(
1293 &board.id,
1294 &item_id,
1295 &field_id,
1296 json!({"singleSelectOptionId":option_id}),
1297 )
1298 .await?;
1299 }
1300
1301 if content_kind == ContentKind::Issue {
1302 self.reparent(
1303 existing.and_then(|item| item.parent.clone()),
1304 &content_id,
1305 incoming.parent,
1306 )
1307 .await?;
1308 self.reconcile_blocked_by(&content_id, &native).await?;
1309 }
1310 Ok(content_id)
1311 }
1312
1313 async fn partition_edges(
1315 &self,
1316 board: &Board,
1317 near_kind: ItemKind,
1318 near_content: ContentKind,
1319 depends_on: &[DependencyEdge],
1320 ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
1321 let mut native = Vec::new();
1322 let mut fallback = Vec::new();
1323 for edge in depends_on {
1324 let same_source = edge
1325 .to
1326 .source()
1327 .is_none_or(|source| source == self.name.as_str());
1328 let far_id = edge
1329 .to
1330 .id()
1331 .rsplit_once(':')
1332 .map_or(edge.to.id(), |(_, id)| id);
1333 let far = if same_source {
1334 Some(
1335 board
1336 .items
1337 .iter()
1338 .find(|item| item.id.0 == far_id)
1339 .ok_or_else(|| SourceError::Refused {
1340 message: format!("GitHub dependency item {far_id} was not found"),
1341 })?,
1342 )
1343 } else {
1344 None
1345 };
1346 if let Some(disagreeing) = far.filter(|far| far.kind != edge.to.kind) {
1352 return Err(SourceError::Refused {
1353 message: format!(
1354 "GitHub dependency item {far_id} is a {} of this board, and this item \
1355 names it as a {}; record the kind it is",
1356 disagreeing.kind.marker(),
1357 edge.to.kind.marker()
1358 ),
1359 });
1360 }
1361 let native_here = near_content == ContentKind::Issue
1365 && far.is_some_and(|far| {
1366 far.content_kind == ContentKind::Issue && edge.to.kind == near_kind
1367 });
1368 if native_here {
1369 native.push(far_id.to_owned());
1370 } else {
1371 fallback.push(edge.clone());
1372 }
1373 }
1374 Ok((native, fallback))
1375 }
1376
1377 async fn update_existing(
1378 &self,
1379 item: &Resolved,
1380 incoming: &Incoming<'_>,
1381 body: &Option<String>,
1382 status_target: &StatusTarget,
1383 ) -> Result<(), SourceError> {
1384 let (operation, input, pointer) = match item.content_kind {
1385 ContentKind::DraftIssue => (
1386 graphql::UPDATE_DRAFT,
1387 json!({"draftIssueId":item.id.0,"title":incoming.title,"body":body}),
1388 "/updateProjectV2DraftIssue/draftIssue",
1389 ),
1390 ContentKind::Issue => (
1391 graphql::UPDATE_ISSUE,
1392 json!({"id":item.id.0,"title":incoming.title,"body":body,
1393 "stateInput":state_input(status_target)}),
1394 "/updateIssue/issue",
1395 ),
1396 };
1397 let data = self.graphql(operation, json!({"input":input})).await?;
1398 let returned = data
1399 .pointer(pointer)
1400 .ok_or_else(|| SourceError::Malformed {
1401 message: "GitHub item update returned no item".into(),
1402 })?;
1403 if required_str(returned, "id")? != item.id.0 {
1404 return Err(SourceError::Malformed {
1405 message: "GitHub item update returned the wrong item".into(),
1406 });
1407 }
1408 Ok(())
1409 }
1410
1411 async fn create_and_file_issue(
1417 &self,
1418 board: &Board,
1419 incoming: &Incoming<'_>,
1420 body: &Option<String>,
1421 status_target: &StatusTarget,
1422 ) -> Result<(NativeId, String), SourceError> {
1423 let repository_id = self.repository_id().await?;
1424 let data = self
1425 .graphql(
1426 graphql::CREATE_ISSUE,
1427 json!({"input":{
1428 "repositoryId":repository_id,"title":incoming.title,"body":body
1429 }}),
1430 )
1431 .await?;
1432 let created = data
1433 .pointer("/createIssue/issue")
1434 .filter(|value| !value.is_null())
1435 .ok_or_else(|| SourceError::Malformed {
1436 message: "GitHub issue creation returned no issue".into(),
1437 })?;
1438 let content_id = NativeId(required_str(created, "id")?.to_owned());
1439 let added = self
1440 .graphql(
1441 graphql::ADD_TO_BOARD,
1442 json!({"input":{"projectId":board.id,"contentId":content_id.0}}),
1443 )
1444 .await?;
1445 let item = added
1446 .pointer("/addProjectV2ItemById/item")
1447 .filter(|value| !value.is_null())
1448 .ok_or_else(|| SourceError::Malformed {
1449 message: "GitHub board addition returned no project item".into(),
1450 })?;
1451 if let StatusTarget::Closed(_) = status_target {
1452 let closed = self
1453 .graphql(
1454 graphql::UPDATE_ISSUE,
1455 json!({"input":{"id":content_id.0,"stateInput":state_input(status_target)}}),
1456 )
1457 .await?;
1458 let returned =
1459 closed
1460 .pointer("/updateIssue/issue")
1461 .ok_or_else(|| SourceError::Malformed {
1462 message: "GitHub item update returned no item".into(),
1463 })?;
1464 if required_str(returned, "id")? != content_id.0 {
1465 return Err(SourceError::Malformed {
1466 message: "GitHub item update returned the wrong item".into(),
1467 });
1468 }
1469 }
1470 Ok((content_id, required_str(item, "id")?.to_owned()))
1471 }
1472
1473 async fn reparent(
1475 &self,
1476 held: Option<NativeId>,
1477 child: &NativeId,
1478 wanted: Option<&NativeId>,
1479 ) -> Result<(), SourceError> {
1480 if held.as_ref() == wanted {
1481 return Ok(());
1482 }
1483 if let Some(held) = &held {
1484 self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
1485 .await?;
1486 }
1487 if let Some(wanted) = wanted {
1488 self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
1489 .await?;
1490 }
1491 Ok(())
1492 }
1493
1494 async fn sub_issue(
1495 &self,
1496 operation: &str,
1497 parent: &NativeId,
1498 child: &NativeId,
1499 root: &str,
1500 ) -> Result<(), SourceError> {
1501 let data = self
1502 .graphql(
1503 operation,
1504 json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
1505 )
1506 .await?;
1507 let issue =
1508 data.pointer(&format!("/{root}/issue"))
1509 .ok_or_else(|| SourceError::Malformed {
1510 message: "GitHub sub-issue update returned no issue".into(),
1511 })?;
1512 let sub =
1513 data.pointer(&format!("/{root}/subIssue"))
1514 .ok_or_else(|| SourceError::Malformed {
1515 message: "GitHub sub-issue update returned no sub-issue".into(),
1516 })?;
1517 if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
1518 return Err(SourceError::Malformed {
1519 message: "GitHub sub-issue update returned the wrong issues".into(),
1520 });
1521 }
1522 Ok(())
1523 }
1524
1525 async fn reconcile_blocked_by(
1526 &self,
1527 content_id: &NativeId,
1528 native: &[String],
1529 ) -> Result<(), SourceError> {
1530 let current = self.native_dependency_ids(content_id).await?;
1531 for (operation, far_id) in current
1532 .iter()
1533 .filter(|id| !native.contains(id))
1534 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1535 .chain(
1536 native
1537 .iter()
1538 .filter(|id| !current.contains(id))
1539 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1540 )
1541 {
1542 let data = self
1543 .graphql(
1544 operation,
1545 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1546 )
1547 .await?;
1548 let root = if operation == graphql::ADD_BLOCKED_BY {
1549 "addBlockedBy"
1550 } else {
1551 "removeBlockedBy"
1552 };
1553 let issue =
1554 data.pointer(&format!("/{root}/issue"))
1555 .ok_or_else(|| SourceError::Malformed {
1556 message: "GitHub dependency update returned no issue".into(),
1557 })?;
1558 let blocker = data
1559 .pointer(&format!("/{root}/blockingIssue"))
1560 .ok_or_else(|| SourceError::Malformed {
1561 message: "GitHub dependency update returned no blocking issue".into(),
1562 })?;
1563 if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
1564 {
1565 return Err(SourceError::Malformed {
1566 message: "GitHub dependency update returned the wrong issues".into(),
1567 });
1568 }
1569 }
1570 Ok(())
1571 }
1572}
1573
1574struct Board {
1576 id: String,
1577 fields: Value,
1578 items: Vec<Resolved>,
1579}
1580
1581impl Board {
1582 fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
1583 complete_connection(fields, "project fields")?;
1584 let nodes = fields
1585 .get("nodes")
1586 .and_then(Value::as_array)
1587 .ok_or_else(|| SourceError::Malformed {
1588 message: "GitHub project fields.nodes is not an array".into(),
1589 })?;
1590 Ok(nodes
1591 .iter()
1592 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
1593 }
1594}
1595
1596struct Resolved {
1598 item_id: String,
1599 id: NativeId,
1600 content_kind: ContentKind,
1601 kind: ItemKind,
1602 title: String,
1603 body: Option<String>,
1604 status: Status,
1605 labels: Vec<Label>,
1606 parent: Option<NativeId>,
1607 origin: Option<String>,
1609 url: Option<String>,
1610 created_at: Option<DateTime<Utc>>,
1611 updated_at: Option<DateTime<Utc>>,
1612 own_repository: Option<Repository>,
1613 repositories: Vec<Repository>,
1614 slot: BTreeMap<String, Value>,
1615}
1616
1617impl Resolved {
1618 fn metadata(&self) -> BTreeMap<String, Value> {
1621 let mut metadata = self.slot.clone();
1622 metadata.remove(Repository::METADATA_KEY);
1623 metadata.remove(DependencyEdge::RECORDED_KEY);
1624 metadata.remove(ItemKind::METADATA_KEY);
1625 if let Some(origin) = &self.origin {
1626 metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
1627 }
1628 metadata
1629 }
1630
1631 fn task(&self) -> Task {
1632 Task {
1633 id: self.id.clone(),
1634 title: self.title.clone(),
1635 content: self.body.clone(),
1636 status: self.status.clone(),
1637 labels: self.labels.clone(),
1638 project: self.parent.clone(),
1639 url: self.url.clone(),
1640 created_at: self.created_at,
1641 updated_at: self.updated_at,
1642 metadata: self.metadata(),
1643 repositories: self.repositories.clone(),
1644 }
1645 }
1646
1647 fn project(&self) -> Project {
1648 Project {
1649 id: self.id.clone(),
1650 title: self.title.clone(),
1651 content: self.body.clone(),
1652 status: self.status.clone(),
1653 labels: self.labels.clone(),
1654 url: self.url.clone(),
1655 created_at: self.created_at,
1656 updated_at: self.updated_at,
1657 metadata: self.metadata(),
1658 repositories: self.repositories.clone(),
1659 }
1660 }
1661}
1662
1663struct Incoming<'a> {
1665 kind: ItemKind,
1666 title: &'a str,
1667 content: Option<&'a str>,
1668 status: &'a Status,
1669 labels: &'a [Label],
1670 metadata: &'a BTreeMap<String, Value>,
1671 repositories: &'a [Repository],
1672 parent: Option<&'a NativeId>,
1673}
1674
1675#[derive(Clone, Copy, PartialEq, Eq)]
1676enum ContentKind {
1677 DraftIssue,
1678 Issue,
1679}
1680
1681#[async_trait::async_trait]
1682impl TaskSource for GitHubProjectsSource {
1683 fn kind(&self) -> &'static str {
1684 KIND
1685 }
1686 fn capabilities(&self) -> Capabilities {
1687 Capabilities {
1688 projects: Support::Native,
1689 orphan_tasks: Support::Unsupported,
1690 filter_by_label: Support::Unsupported,
1691 filter_by_status: Support::Unsupported,
1692 search_title: Support::Unsupported,
1693 search_content: Support::Unsupported,
1694 task_dependencies: DependencySupport::BothDirections,
1695 project_dependencies: DependencySupport::BothDirections,
1696 max_page_size: MAX_PAGE_SIZE,
1697 }
1698 }
1699 async fn health(&self) -> Result<Health, SourceError> {
1700 let board = self.board_page(None, 1).await?;
1701 Ok(Health {
1702 reachable: true,
1703 detail: Some(format!(
1704 "reading GitHub project {}/{} ({})",
1705 self.owner,
1706 self.project_number,
1707 required_str(&board, "title")?
1708 )),
1709 })
1710 }
1711 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1712 Ok(self
1713 .board()
1714 .await?
1715 .items
1716 .iter()
1717 .find(|item| item.id == *id && item.kind == ItemKind::Task)
1718 .map(Resolved::task))
1719 }
1720 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1721 Ok(self
1722 .board()
1723 .await?
1724 .items
1725 .iter()
1726 .find(|item| item.id == *id && item.kind == ItemKind::Project)
1727 .map(Resolved::project))
1728 }
1729 async fn query_tasks(
1730 &self,
1731 _query: &TaskQuery,
1732 page: &PageRequest,
1733 ) -> Result<Page<Task>, SourceError> {
1734 validate_page(page)?;
1735 let tasks = self
1736 .board()
1737 .await?
1738 .items
1739 .iter()
1740 .filter(|item| item.kind == ItemKind::Task)
1741 .map(Resolved::task)
1742 .collect();
1743 Ok(offset_page(
1744 tasks,
1745 numeric_cursor(page.cursor.as_ref())?,
1746 page.limit.min(MAX_PAGE_SIZE) as usize,
1747 ))
1748 }
1749 async fn query_projects(
1750 &self,
1751 _query: &ProjectQuery,
1752 page: &PageRequest,
1753 ) -> Result<Page<Project>, SourceError> {
1754 validate_page(page)?;
1755 let projects = self
1756 .board()
1757 .await?
1758 .items
1759 .iter()
1760 .filter(|item| item.kind == ItemKind::Project)
1761 .map(Resolved::project)
1762 .collect();
1763 Ok(offset_page(
1764 projects,
1765 numeric_cursor(page.cursor.as_ref())?,
1766 page.limit.min(MAX_PAGE_SIZE) as usize,
1767 ))
1768 }
1769 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1770 validate_page(page)?;
1771 let offset = numeric_cursor(page.cursor.as_ref())?;
1772 let mut labels = self
1773 .board()
1774 .await?
1775 .items
1776 .into_iter()
1777 .flat_map(|item| item.labels)
1778 .fold(Vec::new(), |mut all, label| {
1779 if !all.iter().any(|x: &Label| x.id == label.id) {
1780 all.push(label);
1781 }
1782 all
1783 });
1784 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
1785 Ok(offset_page(
1786 labels,
1787 offset,
1788 page.limit.min(MAX_PAGE_SIZE) as usize,
1789 ))
1790 }
1791 async fn task_dependencies(
1792 &self,
1793 id: &NativeId,
1794 direction: Direction,
1795 page: &PageRequest,
1796 ) -> Result<Page<DependencyEdge>, SourceError> {
1797 self.dependencies(id, ItemKind::Task, direction, page).await
1798 }
1799 async fn project_dependencies(
1800 &self,
1801 id: &NativeId,
1802 direction: Direction,
1803 page: &PageRequest,
1804 ) -> Result<Page<DependencyEdge>, SourceError> {
1805 self.dependencies(id, ItemKind::Project, direction, page)
1806 .await
1807 }
1808
1809 fn writes(&self) -> WriteSupport {
1810 WriteSupport::Supported
1811 }
1812
1813 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1814 self.write_item(
1815 &Incoming {
1816 kind: ItemKind::Task,
1817 title: &write.item.title,
1818 content: write.item.content.as_deref(),
1819 status: &write.item.status,
1820 labels: &write.item.labels,
1821 metadata: &write.item.metadata,
1822 repositories: &write.item.repositories,
1823 parent: write.item.project.as_ref(),
1824 },
1825 write.target.as_ref(),
1826 &write.depends_on,
1827 )
1828 .await
1829 }
1830
1831 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1832 self.write_item(
1833 &Incoming {
1834 kind: ItemKind::Project,
1835 title: &write.item.title,
1836 content: write.item.content.as_deref(),
1837 status: &write.item.status,
1838 labels: &write.item.labels,
1839 metadata: &write.item.metadata,
1840 repositories: &write.item.repositories,
1841 parent: None,
1842 },
1843 write.target.as_ref(),
1844 &write.depends_on,
1845 )
1846 .await
1847 }
1848}
1849
1850const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1853
1854const ORIGIN_FIELD: &str = "onetaskgraph.origin";
1859
1860const ORIGIN_KEY: &str = "onetaskgraph.origin";
1874
1875fn recorded_offset(
1883 cursor: Option<&str>,
1884 direction: Direction,
1885) -> Result<Option<usize>, SourceError> {
1886 cursor
1887 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
1888 .map(|offset| {
1889 if direction != Direction::DependsOn {
1890 return Err(SourceError::Config {
1891 message: format!(
1892 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
1893 reverse dependency read never issues; resume it in the direction \
1894 that reported it"
1895 ),
1896 });
1897 }
1898 offset.parse().map_err(|_| SourceError::Config {
1899 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
1900 })
1901 })
1902 .transpose()
1903}
1904
1905fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1906 let mut page = offset_page(edges, offset, limit.max(1));
1907 page.next = page
1908 .next
1909 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
1910 page
1911}
1912
1913fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
1919 let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
1920 if parent.is_some() {
1921 return Ok(ItemKind::Task);
1922 }
1923 let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
1924 let id = required_str(value, "id")?;
1925 let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
1926 message: format!("GitHub issue {id}: {message}"),
1927 })?;
1928 let sub_issues = sub_issue_total(value)?;
1929 Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
1930 ItemKind::Project
1931 } else {
1932 ItemKind::Task
1933 })
1934}
1935
1936fn state_input(target: &StatusTarget) -> Value {
1943 match target {
1944 StatusTarget::Closed(reason) => json!({"value":"CLOSED","stateReason":reason.reason()}),
1945 StatusTarget::Column(_) | StatusTarget::Disabled => json!({"value":"OPEN"}),
1946 }
1947}
1948
1949fn slot_metadata(
1956 incoming: &Incoming<'_>,
1957 own_repository: Option<&Repository>,
1958 fallback: &[DependencyEdge],
1959) -> BTreeMap<String, Value> {
1960 let mut metadata = incoming.metadata.clone();
1961 metadata.remove(ORIGIN_KEY);
1962 metadata.insert(
1963 ItemKind::METADATA_KEY.to_owned(),
1964 Value::String(incoming.kind.marker().to_owned()),
1965 );
1966 let derivable = own_repository
1967 .map(|own| incoming.repositories == [own.clone()])
1968 .unwrap_or(incoming.repositories.is_empty());
1969 if derivable {
1970 metadata.remove(Repository::METADATA_KEY);
1971 } else {
1972 metadata.insert(
1973 Repository::METADATA_KEY.to_owned(),
1974 Value::Array(
1975 incoming
1976 .repositories
1977 .iter()
1978 .map(|repository| Value::String(repository.as_str().to_owned()))
1979 .collect(),
1980 ),
1981 );
1982 }
1983 if fallback.is_empty() {
1984 metadata.remove(DependencyEdge::RECORDED_KEY);
1985 } else {
1986 metadata.insert(
1987 DependencyEdge::RECORDED_KEY.to_owned(),
1988 Value::Array(
1989 fallback
1990 .iter()
1991 .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
1992 .collect(),
1993 ),
1994 );
1995 }
1996 metadata
1997}
1998
1999fn labels(content: &Value, field_values: &[Value]) -> Result<Vec<Label>, SourceError> {
2000 let direct = optional_nodes(content.get("labels"), "content labels")?;
2001 let field = field_values
2002 .iter()
2003 .find_map(|value| value.get("labels"))
2004 .map(|labels| optional_nodes(Some(labels), "field labels"))
2005 .transpose()?
2006 .flatten();
2007 let labels = direct
2008 .into_iter()
2009 .flatten()
2010 .chain(field.into_iter().flatten())
2011 .map(|v| {
2012 Ok(Label {
2013 id: NativeId(required_str(v, "id")?.to_owned()),
2014 name: required_str(v, "name")?.to_owned(),
2015 color: optional_str(v, "color")?.map(str::to_owned),
2016 })
2017 })
2018 .collect::<Result<Vec<_>, SourceError>>()?
2019 .into_iter()
2020 .fold(Vec::new(), |mut labels, label| {
2021 if !labels.iter().any(|x: &Label| x.id == label.id) {
2022 labels.push(label);
2023 }
2024 labels
2025 });
2026 Ok(labels)
2027}
2028
2029fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
2030 let Some(node) = field_values
2031 .iter()
2032 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
2033 else {
2034 return Ok(None);
2035 };
2036 Ok(optional_str(node, "text")?.map(str::to_owned))
2037}
2038
2039fn valid_github_owner(owner: &str) -> bool {
2040 !owner.is_empty()
2041 && owner.len() <= 39
2042 && !owner.starts_with('-')
2043 && !owner.ends_with('-')
2044 && !owner.contains("--")
2045 && owner
2046 .bytes()
2047 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2048}
2049
2050fn valid_github_repository_name(name: &str) -> bool {
2053 !name.is_empty()
2054 && name.len() <= 100
2055 && name != "."
2056 && name != ".."
2057 && name
2058 .bytes()
2059 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2060}
2061
2062fn valid_environment_name(name: &str) -> bool {
2063 let mut bytes = name.bytes();
2064 bytes
2065 .next()
2066 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2067 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2068}
2069
2070fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
2077 let summary = issue
2078 .get("subIssuesSummary")
2079 .ok_or_else(|| SourceError::Malformed {
2080 message: "GitHub issue is missing subIssuesSummary".into(),
2081 })?;
2082 summary
2083 .get("total")
2084 .and_then(Value::as_u64)
2085 .ok_or_else(|| SourceError::Malformed {
2086 message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
2087 })
2088}
2089
2090fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2091 value
2092 .get(field)
2093 .and_then(Value::as_str)
2094 .ok_or_else(|| SourceError::Malformed {
2095 message: format!("GitHub response is missing string field {field}"),
2096 })
2097}
2098
2099const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2107const METADATA_CLOSE: &str = "\n-->";
2108
2109fn metadata_body(
2115 body: Option<String>,
2116) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
2117 let Some(body) = body else {
2118 return Ok((None, BTreeMap::new()));
2119 };
2120 let Some(start) = body.rfind(METADATA_OPEN) else {
2121 return Ok((Some(body), BTreeMap::new()));
2122 };
2123 let encoded_start = start + METADATA_OPEN.len();
2124 let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
2125 return Err(SourceError::Malformed {
2126 message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
2127 });
2128 };
2129 let encoded_end = encoded_start + relative_end;
2130 if !body[encoded_end + METADATA_CLOSE.len()..].trim().is_empty() {
2131 return Ok((Some(body), BTreeMap::new()));
2132 }
2133 let metadata = serde_json::from_str(&body[encoded_start..encoded_end]).map_err(|error| {
2134 SourceError::Malformed {
2135 message: format!(
2136 "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
2137 ),
2138 }
2139 })?;
2140 let visible = body[..start].trim_end();
2141 Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2142}
2143
2144fn compose_body(
2145 content: Option<&str>,
2146 metadata: &BTreeMap<String, Value>,
2147) -> Result<Option<String>, SourceError> {
2148 let visible = content.unwrap_or_default();
2149 if metadata.is_empty() {
2150 return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2151 }
2152 let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
2153 message: error.to_string(),
2154 })?;
2155 Ok(Some(if visible.is_empty() {
2156 format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2157 } else {
2158 format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2159 }))
2160}
2161
2162fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
2163 value
2164 .get(field)
2165 .and_then(Value::as_bool)
2166 .ok_or_else(|| SourceError::Malformed {
2167 message: format!("GitHub response is missing boolean field {field}"),
2168 })
2169}
2170fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
2171 match value.get(field) {
2172 None | Some(Value::Null) => Ok(None),
2173 Some(value) => value
2174 .as_str()
2175 .map(Some)
2176 .ok_or_else(|| SourceError::Malformed {
2177 message: format!("GitHub response field {field} is not a string or null"),
2178 }),
2179 }
2180}
2181fn optional_nodes<'a>(
2182 connection: Option<&'a Value>,
2183 name: &str,
2184) -> Result<Option<&'a Vec<Value>>, SourceError> {
2185 match connection {
2186 None | Some(Value::Null) => Ok(None),
2187 Some(value) => value
2188 .get("nodes")
2189 .and_then(Value::as_array)
2190 .map(Some)
2191 .ok_or_else(|| SourceError::Malformed {
2192 message: format!("GitHub {name}.nodes is not an array"),
2193 }),
2194 }
2195}
2196fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
2197 let page_info = connection
2198 .get("pageInfo")
2199 .ok_or_else(|| SourceError::Malformed {
2200 message: format!("GitHub {name} has no pageInfo"),
2201 })?;
2202 if required_bool(page_info, "hasNextPage")? {
2203 return Err(SourceError::Malformed {
2204 message: format!(
2205 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
2206 ),
2207 });
2208 }
2209 Ok(())
2210}
2211fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2212 optional_str(value, field)?
2213 .map(|timestamp| {
2214 timestamp.parse().map_err(|error| SourceError::Malformed {
2215 message: format!("GitHub response field {field} is not a timestamp: {error}"),
2216 })
2217 })
2218 .transpose()
2219}
2220fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
2221 if page.limit == 0 {
2222 Err(SourceError::Config {
2223 message: "page limit must be at least 1".into(),
2224 })
2225 } else {
2226 Ok(())
2227 }
2228}
2229fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
2230 let page = connection
2231 .get("pageInfo")
2232 .filter(|value| value.is_object())
2233 .ok_or_else(|| SourceError::Malformed {
2234 message: "GitHub connection is missing pageInfo".into(),
2235 })?;
2236 if required_bool(page, "hasNextPage")? {
2237 let cursor = required_str(page, "endCursor")?;
2238 validate_cursor_progress(None, cursor)?;
2239 Ok(Some(Cursor(cursor.into())))
2240 } else {
2241 Ok(None)
2242 }
2243}
2244fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
2245 if next.is_empty() || previous == Some(next) {
2246 Err(SourceError::Malformed {
2247 message: "GitHub pagination cursor is empty or did not advance".into(),
2248 })
2249 } else {
2250 Ok(())
2251 }
2252}
2253fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
2254 cursor.map_or(Ok(0), |c| {
2255 c.0.parse().map_err(|_| SourceError::Config {
2256 message: "page cursor is invalid".into(),
2257 })
2258 })
2259}
2260fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
2261 if offset > items.len() {
2262 return Page::last(vec![]);
2263 }
2264 let tail = items.split_off(offset);
2265 let mut selected = tail;
2266 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
2267 selected.truncate(limit);
2268 Page {
2269 items: selected,
2270 next,
2271 }
2272}