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