1#![deny(missing_docs)]
37
38use std::collections::BTreeMap;
39
40use chrono::{DateTime, Utc};
41use onetaskgraph_plugin_api::{
42 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
43 Direction, Health, ItemKind, ItemWrite, Label, NativeId, Page, PageRequest, Project,
44 ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin, Status,
45 StatusCategory, Support, Task, TaskQuery, TaskSource, WriteSupport,
46};
47use reqwest::{Client, StatusCode, Url};
48use schemars::{Schema, schema_for};
49use secrecy::{ExposeSecret, SecretString};
50use serde::Deserialize;
51use serde_json::{Value, json};
52
53pub const KIND: &str = "github-projects";
55pub const MAX_PAGE_SIZE: u32 = 100;
57const NESTED_PAGE_SIZE: u32 = 50;
59
60pub mod graphql {
65 pub const PROJECT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){
67 owner:repositoryOwner(login:$owner){
68 ... on ProjectV2Owner{projectV2(number:$number){...Project}}
69 }
70 } fragment Project on ProjectV2 { id title shortDescription url createdAt updatedAt closed
71 fields(first:$nestedFirst){nodes{
72 ... on ProjectV2SingleSelectField{__typename id name options{id name}}
73 ... on ProjectV2Field{__typename id name}
74 }pageInfo{hasNextPage}}
75 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
76 ... on ProjectV2ItemFieldSingleSelectValue{name field{
77 ... on ProjectV2SingleSelectField{id name options{id name}}
78 }}
79 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
80 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
81 }pageInfo{hasNextPage}} content{
82 ... on Issue{__typename id title body url createdAt updatedAt state repository{nameWithOwner} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
83 ... on PullRequest{__typename id title body url createdAt updatedAt state repository{nameWithOwner} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
84 ... on DraftIssue{__typename id title body createdAt updatedAt}
85 }} pageInfo{hasNextPage endCursor}}
86 }"#;
87 pub const TASK_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename ... on Issue{blockedBy(first:$first,after:$after){nodes{id}pageInfo{hasNextPage endCursor}}blocking(first:$first,after:$after){nodes{id}pageInfo{hasNextPage endCursor}}}}}"#;
89 pub const RELATED_PROJECTS: &str = r#"query($id:ID!,$first:Int!,$after:String!){node(id:$id){... on Issue{projectItems(first:$first,after:$after){nodes{project{id}}pageInfo{hasNextPage endCursor}}}}}"#;
91 pub const PROJECT_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String,$nestedFirst:Int!){node(id:$id){... on Issue{blockedBy(first:$first,after:$after){nodes{id projectItems(first:$nestedFirst){nodes{project{id}}pageInfo{hasNextPage endCursor}}}pageInfo{hasNextPage endCursor}}blocking(first:$first,after:$after){nodes{id projectItems(first:$nestedFirst){nodes{project{id}}pageInfo{hasNextPage endCursor}}}pageInfo{hasNextPage endCursor}}}}}"#;
93 pub const CREATE_DRAFT: &str = r#"mutation($input:AddProjectV2DraftIssueInput!){addProjectV2DraftIssue(input:$input){projectItem{id content{... on DraftIssue{id}}}}}"#;
95 pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
97 pub const UPDATE_ISSUE: &str =
99 r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
100 pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
102 pub const UPDATE_PROJECT: &str =
104 r#"mutation($input:UpdateProjectV2Input!){updateProjectV2(input:$input){projectV2{id}}}"#;
105 pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
107 pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
109}
110
111fn default_token_env() -> String {
112 "GH_PROJECTS_TOKEN".to_owned()
113}
114fn default_endpoint() -> String {
115 "https://api.github.com/graphql".to_owned()
116}
117
118#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
120#[serde(default, deny_unknown_fields)]
121pub struct GitHubProjectsConfig {
122 pub owner: String, pub project_number: u32, #[serde(default = "default_token_env")]
129 pub token_env: String, #[serde(default = "default_endpoint")]
132 pub endpoint: String, #[serde(default)]
135 pub status_mapping: BTreeMap<String, StatusCategory>, }
137
138#[derive(Debug, Clone, Copy, Default)]
140pub struct Plugin;
141
142impl SourcePlugin for Plugin {
143 fn kind(&self) -> &'static str {
144 KIND
145 }
146 fn config_schema(&self) -> Schema {
147 schema_for!(GitHubProjectsConfig)
148 }
149 fn build(
150 &self,
151 name: &SourceName,
152 config: &Value,
153 secrets: &dyn SecretResolver,
154 ) -> Result<Box<dyn TaskSource>, SourceError> {
155 let config: GitHubProjectsConfig =
156 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
157 message: format!("source {name}: {e}"),
158 })?;
159 let source =
160 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
161 SourceError::Config { message } => SourceError::Config {
162 message: format!("source {name}: {message}"),
163 },
164 SourceError::Auth { message } => SourceError::Auth {
165 message: format!("source {name}: {message}"),
166 },
167 other => other,
168 })?;
169 Ok(Box::new(source))
170 }
171}
172
173pub struct GitHubProjectsSource {
175 name: SourceName,
178 owner: String, project_number: u32, endpoint: Url,
181 token: SecretString,
182 credential_name: String, statuses: BTreeMap<StatusName, StatusCategory>,
184 client: Client,
185}
186
187impl GitHubProjectsSource {
188 pub fn new(
194 name: &SourceName,
195 config: GitHubProjectsConfig,
196 secrets: &dyn SecretResolver,
197 ) -> Result<Self, SourceError> {
198 if !valid_github_owner(&config.owner) {
199 return Err(SourceError::Config {
200 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
201 });
202 }
203 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
204 return Err(SourceError::Config {
205 message: format!("project_number must be between 1 and {}", i32::MAX),
206 });
207 }
208 if !valid_environment_name(&config.token_env) {
209 return Err(SourceError::Config {
210 message: "token_env must be a valid environment-variable name".into(),
211 });
212 }
213 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
214 message: format!("endpoint is not a valid URL: {e}"),
215 })?;
216 if endpoint.scheme() != "https"
217 && !(endpoint.scheme() == "http"
218 && endpoint
219 .host_str()
220 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
221 {
222 return Err(SourceError::Config {
223 message:
224 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
225 .into(),
226 });
227 }
228 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
229 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),
230 })?;
231 Ok(Self {
232 name: name.clone(),
233 owner: config.owner,
234 project_number: config.project_number,
235 endpoint,
236 token,
237 credential_name: config.token_env,
238 statuses: normalize_status_mapping(config.status_mapping)?,
239 client: Client::builder()
240 .user_agent("onetaskgraph")
241 .build()
242 .map_err(|e| SourceError::Config {
243 message: format!("cannot build HTTP client: {e}"),
244 })?,
245 })
246 }
247
248 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
249 let response = self
250 .client
251 .post(self.endpoint.clone())
252 .bearer_auth(self.token.expose_secret())
253 .json(&json!({"query": query, "variables": variables}))
254 .send()
255 .await
256 .map_err(|e| SourceError::Unavailable {
257 message: format!("GitHub GraphQL request failed: {e}"),
258 })?;
259 let status = response.status();
260 let retry_after = response
261 .headers()
262 .get("retry-after")
263 .and_then(|v| v.to_str().ok())
264 .and_then(|v| v.parse().ok());
265 let exhausted = response
266 .headers()
267 .get("x-ratelimit-remaining")
268 .and_then(|v| v.to_str().ok())
269 == Some("0");
270 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
271 return Err(SourceError::RateLimited {
272 retry_after_seconds: retry_after,
273 });
274 }
275 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
276 return Err(SourceError::Auth {
277 message: format!(
278 "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"
279 ),
280 });
281 }
282 if !status.is_success() {
283 return Err(SourceError::Unavailable {
284 message: format!("GitHub GraphQL returned HTTP {status}"),
285 });
286 }
287 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
288 message: format!("GitHub returned invalid JSON: {e}"),
289 })?;
290 let errors = body
291 .get("errors")
292 .map(|value| {
293 value.as_array().ok_or_else(|| SourceError::Malformed {
294 message: "GitHub response errors is not an array".into(),
295 })
296 })
297 .transpose()?;
298 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
299 let messages = errors
300 .iter()
301 .filter_map(|e| e.get("message").and_then(Value::as_str))
302 .collect::<Vec<_>>()
303 .join("; ");
304 let message = if messages.is_empty() {
305 "GitHub returned GraphQL errors".into()
306 } else {
307 messages
308 };
309 let normalized = message.to_ascii_lowercase();
310 if normalized.contains("resource not accessible") || normalized.contains("scope") {
311 return Err(SourceError::Auth {
312 message: format!(
313 "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
314 self.credential_name
315 ),
316 });
317 }
318 return Err(SourceError::Refused { message });
319 }
320 body.get("data")
321 .filter(|data| data.is_object())
322 .cloned()
323 .ok_or_else(|| SourceError::Malformed {
324 message: "GitHub response has no data object".into(),
325 })
326 }
327
328 async fn project_value(
332 &self,
333 items_after: Option<&str>,
334 items_first: u32,
335 ) -> Result<Value, SourceError> {
336 let data = self.graphql(graphql::PROJECT, json!({"owner":self.owner,"number":self.project_number,"first":items_first.min(MAX_PAGE_SIZE),"after":items_after,"nestedFirst":NESTED_PAGE_SIZE})).await?;
337 let project = data
338 .pointer("/owner/projectV2")
339 .filter(|v| !v.is_null())
340 .cloned()
341 .ok_or_else(|| SourceError::Refused {
342 message: format!(
343 "GitHub project {}/{} was not found or is not visible to the token",
344 self.owner, self.project_number
345 ),
346 })?;
347 Ok(project)
348 }
349
350 fn status(&self, item: &Value) -> Result<Status, SourceError> {
351 let fields = item
352 .pointer("/fieldValues/nodes")
353 .and_then(Value::as_array)
354 .expect("task validates fieldValues.nodes before mapping status");
355 let name = fields
356 .iter()
357 .find(|v| v.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
358 .map(|value| required_str(value, "name"))
359 .transpose()?
360 .or(optional_str(
361 item.get("content").unwrap_or(&Value::Null),
362 "state",
363 )?)
364 .unwrap_or("Unknown")
365 .to_owned();
366 let category = self
367 .statuses
368 .get(&StatusName::new(&name))
369 .copied()
370 .unwrap_or_else(|| match name.to_ascii_lowercase().as_str() {
371 "backlog" => StatusCategory::Backlog,
372 "todo" | "open" => StatusCategory::Todo,
373 "in progress" | "in review" => StatusCategory::InProgress,
374 "done" | "closed" | "merged" => StatusCategory::Done,
375 "cancelled" | "canceled" => StatusCategory::Cancelled,
376 _ => StatusCategory::Unknown,
377 });
378 Ok(Status { category, name })
379 }
380
381 fn labels(item: &Value) -> Result<Vec<Label>, SourceError> {
382 let direct = optional_nodes(item.pointer("/content/labels"), "content labels")?;
383 let field_values = item
384 .pointer("/fieldValues/nodes")
385 .and_then(Value::as_array)
386 .expect("task validates fieldValues.nodes before mapping labels");
387 let field = field_values
388 .iter()
389 .find_map(|value| value.get("labels"))
390 .map(|labels| optional_nodes(Some(labels), "field labels"))
391 .transpose()?
392 .flatten();
393 let labels = direct
394 .into_iter()
395 .flatten()
396 .chain(field.into_iter().flatten())
397 .map(|v| {
398 Ok(Label {
399 id: NativeId(required_str(v, "id")?.to_owned()),
400 name: required_str(v, "name")?.to_owned(),
401 color: optional_str(v, "color")?.map(str::to_owned),
402 })
403 })
404 .collect::<Result<Vec<_>, SourceError>>()?
405 .into_iter()
406 .fold(Vec::new(), |mut labels, label| {
407 if !labels.iter().any(|x: &Label| x.id == label.id) {
408 labels.push(label);
409 }
410 labels
411 });
412 Ok(labels)
413 }
414
415 fn task(&self, project_id: &str, item: &Value) -> Result<Option<Task>, SourceError> {
416 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
417 message: "GitHub project item is missing content".into(),
418 })?;
419 if content.is_null() {
420 return Ok(None);
421 }
422 let field_values = item
423 .get("fieldValues")
424 .ok_or_else(|| SourceError::Malformed {
425 message: "GitHub project item is missing fieldValues".into(),
426 })?;
427 complete_connection(field_values, "project item field values")?;
428 field_values
429 .get("nodes")
430 .and_then(Value::as_array)
431 .ok_or_else(|| SourceError::Malformed {
432 message: "GitHub project item fieldValues.nodes is not an array".into(),
433 })?;
434 if let Some(labels) = content.get("labels") {
435 complete_connection(labels, "content labels")?;
436 }
437 for field_value in field_values["nodes"].as_array().expect("validated above") {
438 if let Some(labels) = field_value.get("labels") {
439 complete_connection(labels, "project item field labels")?;
440 }
441 }
442 Ok(Some(Task {
443 id: NativeId(required_str(content, "id")?.to_owned()),
444 title: required_str(content, "title")?.to_owned(),
445 content: optional_str(content, "body")?
446 .filter(|s| !s.is_empty())
447 .map(str::to_owned),
448 status: self.status(item)?,
449 labels: Self::labels(item)?,
450 project: Some(NativeId(project_id.to_owned())),
451 url: optional_str(content, "url")?.map(str::to_owned),
452 created_at: optional_time(content, "createdAt")?,
453 updated_at: optional_time(content, "updatedAt")?,
454 metadata: metadata_field(field_values)?,
455 repositories: repositories(content, field_values)?,
456 }))
457 }
458
459 fn project(&self, value: &Value) -> Result<Project, SourceError> {
460 let id = required_str(value, "id")?;
461 let (content, metadata) =
462 metadata_description(optional_str(value, "shortDescription")?.map(str::to_owned))?;
463 let repositories = repositories_from_metadata(&metadata)?;
464 Ok(Project {
465 id: NativeId(id.into()),
466 title: required_str(value, "title")?.into(),
467 content,
468 status: Status {
469 category: if required_bool(value, "closed")? {
470 StatusCategory::Done
471 } else {
472 StatusCategory::InProgress
473 },
474 name: if required_bool(value, "closed")? {
475 "Closed"
476 } else {
477 "Open"
478 }
479 .into(),
480 },
481 labels: vec![],
482 url: optional_str(value, "url")?.map(str::to_owned),
483 created_at: optional_time(value, "createdAt")?,
484 updated_at: optional_time(value, "updatedAt")?,
485 metadata,
486 repositories,
487 })
488 }
489
490 async fn all_tasks(&self) -> Result<Vec<Task>, SourceError> {
491 let mut after = None;
492 let mut tasks = Vec::new();
493 loop {
494 let project = self.project_value(after.as_deref(), MAX_PAGE_SIZE).await?;
495 let project_id = required_str(&project, "id")?;
496 let items = project
497 .pointer("/items/nodes")
498 .and_then(Value::as_array)
499 .ok_or_else(|| SourceError::Malformed {
500 message: "GitHub project items.nodes is not an array".into(),
501 })?;
502 for item in items {
503 if let Some(task) = self.task(project_id, item)? {
504 tasks.push(task);
505 }
506 }
507 let page =
508 project
509 .pointer("/items/pageInfo")
510 .ok_or_else(|| SourceError::Malformed {
511 message: "GitHub project items have no pageInfo".into(),
512 })?;
513 if !required_bool(page, "hasNextPage")? {
514 break;
515 }
516 let next = required_str(page, "endCursor")?;
517 validate_cursor_progress(after.as_deref(), next)?;
518 after = Some(next.to_owned());
519 }
520 Ok(tasks)
521 }
522
523 async fn board_and_item(
524 &self,
525 content_id: Option<&NativeId>,
526 ) -> Result<(Value, Option<Value>), SourceError> {
527 let mut after = None;
528 loop {
529 let project = self.project_value(after.as_deref(), MAX_PAGE_SIZE).await?;
530 let nodes = project
531 .pointer("/items/nodes")
532 .and_then(Value::as_array)
533 .ok_or_else(|| SourceError::Malformed {
534 message: "GitHub project items.nodes is not an array".into(),
535 })?;
536 let found = content_id.and_then(|wanted| {
537 nodes
538 .iter()
539 .find(|item| {
540 item.pointer("/content/id").and_then(Value::as_str)
541 == Some(wanted.0.as_str())
542 })
543 .cloned()
544 });
545 if found.is_some() || content_id.is_none() {
546 return Ok((project, found));
547 }
548 let page =
549 project
550 .pointer("/items/pageInfo")
551 .ok_or_else(|| SourceError::Malformed {
552 message: "GitHub project items have no pageInfo".into(),
553 })?;
554 if !required_bool(page, "hasNextPage")? {
555 return Ok((project, None));
556 }
557 let next = required_str(page, "endCursor")?;
558 validate_cursor_progress(after.as_deref(), next)?;
559 after = Some(next.to_owned());
560 }
561 }
562
563 async fn set_item_field(
564 &self,
565 project_id: &str,
566 item_id: &str,
567 field_id: &str,
568 value: Value,
569 ) -> Result<(), SourceError> {
570 let data = self
571 .graphql(
572 graphql::UPDATE_FIELD,
573 json!({"input":{
574 "projectId":project_id,"itemId":item_id,"fieldId":field_id,"value":value
575 }}),
576 )
577 .await?;
578 let returned = data
579 .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
580 .ok_or_else(|| SourceError::Malformed {
581 message: "GitHub field update returned no project item".into(),
582 })?;
583 if required_str(returned, "id")? != item_id {
584 return Err(SourceError::Malformed {
585 message: "GitHub field update returned the wrong project item".into(),
586 });
587 }
588 Ok(())
589 }
590
591 async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
592 let mut after = None;
593 let mut ids = Vec::new();
594 loop {
595 let data = self
596 .graphql(
597 graphql::TASK_DEPENDENCIES,
598 json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
599 )
600 .await?;
601 let connection =
602 data.pointer("/node/blockedBy")
603 .ok_or_else(|| SourceError::Malformed {
604 message: "GitHub dependency response has no blockedBy connection".into(),
605 })?;
606 ids.extend(
607 connection
608 .get("nodes")
609 .and_then(Value::as_array)
610 .ok_or_else(|| SourceError::Malformed {
611 message: "GitHub dependency response nodes is not an array".into(),
612 })?
613 .iter()
614 .map(|value| required_str(value, "id").map(str::to_owned))
615 .collect::<Result<Vec<_>, _>>()?,
616 );
617 let next = next_cursor(connection)?;
618 if let Some(next) = &next {
619 validate_cursor_progress(after.as_deref(), &next.0)?;
620 }
621 after = next.map(|cursor| cursor.0);
622 if after.is_none() {
623 return Ok(ids);
624 }
625 }
626 }
627
628 fn field<'a>(project: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
629 complete_connection(
630 project.get("fields").unwrap_or(&Value::Null),
631 "project fields",
632 )?;
633 let fields = project
634 .pointer("/fields/nodes")
635 .and_then(Value::as_array)
636 .ok_or_else(|| SourceError::Malformed {
637 message: "GitHub project fields.nodes is not an array".into(),
638 })?;
639 Ok(fields
640 .iter()
641 .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
642 }
643
644 fn task_metadata(
645 write: &ItemWrite<Task>,
646 repositories: RepositoryStorage,
647 ) -> Result<BTreeMap<String, Value>, SourceError> {
648 let mut metadata = write.item.metadata.clone();
649 if repositories == RepositoryStorage::Recorded && !write.item.repositories.is_empty() {
650 metadata.insert(
651 Repository::METADATA_KEY.into(),
652 Value::Array(
653 write
654 .item
655 .repositories
656 .iter()
657 .map(|repository| Value::String(repository.as_str().to_owned()))
658 .collect(),
659 ),
660 );
661 } else {
662 metadata.remove(Repository::METADATA_KEY);
663 }
664 if !write.depends_on.is_empty() {
665 metadata.insert(
666 DependencyEdge::RECORDED_KEY.into(),
667 Value::Array(
668 write
669 .depends_on
670 .iter()
671 .map(|edge| endpoint_value(&edge.to))
672 .collect(),
673 ),
674 );
675 } else {
676 metadata.remove(DependencyEdge::RECORDED_KEY);
677 }
678 Ok(metadata)
679 }
680
681 async fn dependencies(
682 &self,
683 id: &NativeId,
684 direction: Direction,
685 page: &PageRequest,
686 ) -> Result<Page<DependencyEdge>, SourceError> {
687 validate_page(page)?;
688 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
689 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
690 let recorded = recorded_offset(cursor, direction)?;
691 let data = self
696 .graphql(
697 graphql::TASK_DEPENDENCIES,
698 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
699 "after":if recorded.is_some() {None} else {cursor}}),
700 )
701 .await?;
702 let node =
703 data.get("node")
704 .filter(|v| !v.is_null())
705 .ok_or_else(|| SourceError::Refused {
706 message: format!(
707 "GitHub item {} was not found or does not support dependencies",
708 id.0
709 ),
710 })?;
711 let connection_name = match direction {
712 Direction::DependsOn => "blockedBy",
713 Direction::DependedOnBy => "blocking",
714 };
715 let natively_names =
719 (required_str(node, "__typename")? == "Issue").then_some(ItemKind::Task);
720 if let Some(offset) = recorded {
721 return Ok(recorded_page(
722 self.recorded_task_edges(id, direction, natively_names)
723 .await?,
724 offset,
725 limit,
726 ));
727 }
728 if natively_names.is_none() {
729 return Ok(recorded_page(
730 self.recorded_task_edges(id, direction, natively_names)
731 .await?,
732 0,
733 limit,
734 ));
735 }
736 let connection = node
737 .get(connection_name)
738 .ok_or_else(|| SourceError::Malformed {
739 message: "GitHub dependency response is missing its connection".into(),
740 })?;
741 let nodes = connection
742 .get("nodes")
743 .and_then(Value::as_array)
744 .ok_or_else(|| SourceError::Malformed {
745 message: "GitHub dependency response nodes is not an array".into(),
746 })?;
747 let items = nodes
751 .iter()
752 .map(|value| {
753 let related = NativeId(required_str(value, "id")?.into());
754 let (from, to) = match direction {
755 Direction::DependsOn => (id.clone(), related),
756 Direction::DependedOnBy => (related, id.clone()),
757 };
758 Ok(DependencyEdge {
759 from: DependencyEndpoint::from_native(from, ItemKind::Task),
760 to: DependencyEndpoint::from_native(to, ItemKind::Task),
761 kind: DependencyKind::Blocks,
762 })
763 })
764 .collect::<Result<Vec<_>, SourceError>>()?;
765 let mut next = next_cursor(connection)?;
766 if let Some(next) = &next {
767 validate_cursor_progress(cursor, &next.0)?;
768 }
769 if next.is_none()
770 && !self
771 .recorded_task_edges(id, direction, natively_names)
772 .await?
773 .is_empty()
774 {
775 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
776 }
777 Ok(Page { items, next })
778 }
779
780 async fn recorded_task_edges(
790 &self,
791 id: &NativeId,
792 direction: Direction,
793 natively_names: Option<ItemKind>,
794 ) -> Result<Vec<DependencyEdge>, SourceError> {
795 if direction != Direction::DependsOn {
796 return Ok(Vec::new());
797 }
798 let Some(task) = self
799 .all_tasks()
800 .await?
801 .into_iter()
802 .find(|task| task.id == *id)
803 else {
804 return Ok(Vec::new());
805 };
806 DependencyEdge::recorded(
807 &task.metadata,
808 id,
809 ItemKind::Task,
810 &self.name,
811 natively_names,
812 )
813 .map_err(|message| SourceError::Malformed { message })
814 }
815
816 async fn related_issue_projects(&self, issue: &Value) -> Result<Vec<NativeId>, SourceError> {
817 let issue_id = required_str(issue, "id")?;
818 let mut connection =
819 issue
820 .get("projectItems")
821 .cloned()
822 .ok_or_else(|| SourceError::Malformed {
823 message: "GitHub related issue is missing projectItems".into(),
824 })?;
825 let mut projects = Vec::new();
826 let mut previous = None;
827 loop {
828 let nodes = connection
829 .get("nodes")
830 .and_then(Value::as_array)
831 .ok_or_else(|| SourceError::Malformed {
832 message: "GitHub related issue projectItems.nodes is not an array".into(),
833 })?;
834 for item in nodes {
835 projects.push(NativeId(
836 required_str(
837 item.get("project").ok_or_else(|| SourceError::Malformed {
838 message: "GitHub dependency project item has no project".into(),
839 })?,
840 "id",
841 )?
842 .into(),
843 ));
844 }
845 let Some(cursor) = next_cursor(&connection)? else {
846 break;
847 };
848 validate_cursor_progress(previous.as_deref(), &cursor.0)?;
849 previous = Some(cursor.0.clone());
850 let data = self
851 .graphql(
852 graphql::RELATED_PROJECTS,
853 json!({"id":issue_id,"first":MAX_PAGE_SIZE,"after":cursor.0}),
854 )
855 .await?;
856 connection = data.pointer("/node/projectItems").cloned().ok_or_else(|| {
857 SourceError::Malformed {
858 message: "GitHub related issue response is missing projectItems".into(),
859 }
860 })?;
861 }
862 Ok(projects)
863 }
864}
865
866#[derive(Clone, Copy, PartialEq, Eq)]
867enum ContentKind {
868 DraftIssue,
869 Issue,
870}
871#[derive(Clone, Copy, PartialEq, Eq)]
872enum RepositoryStorage {
873 Native,
874 Recorded,
875}
876impl ContentKind {
877 fn parse(content: &Value) -> Result<Self, SourceError> {
878 match required_str(content, "__typename")? {
879 "DraftIssue" => Ok(Self::DraftIssue),
880 "Issue" => Ok(Self::Issue),
881 other => Err(SourceError::Refused {
882 message: format!("GitHub {other} items cannot be updated by this destination"),
883 }),
884 }
885 }
886}
887
888#[async_trait::async_trait]
889impl TaskSource for GitHubProjectsSource {
890 fn kind(&self) -> &'static str {
891 KIND
892 }
893 fn capabilities(&self) -> Capabilities {
894 Capabilities {
895 projects: Support::Native,
896 orphan_tasks: Support::Unsupported,
897 filter_by_label: Support::Unsupported,
898 filter_by_status: Support::Unsupported,
899 search_title: Support::Unsupported,
900 search_content: Support::Unsupported,
901 task_dependencies: DependencySupport::BothDirections,
902 project_dependencies: DependencySupport::BothDirections,
903 max_page_size: MAX_PAGE_SIZE,
904 }
905 }
906 async fn health(&self) -> Result<Health, SourceError> {
907 let project = self.project_value(None, 1).await?;
908 Ok(Health {
909 reachable: true,
910 detail: Some(format!(
911 "reading GitHub project {}/{} ({})",
912 self.owner,
913 self.project_number,
914 required_str(&project, "title")?
915 )),
916 })
917 }
918 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
919 Ok(self
920 .all_tasks()
921 .await?
922 .into_iter()
923 .find(|task| task.id == *id))
924 }
925 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
926 let value = self.project_value(None, 1).await?;
927 let project = self.project(&value)?;
928 Ok((project.id == *id).then_some(project))
929 }
930 async fn query_tasks(
931 &self,
932 _query: &TaskQuery,
933 page: &PageRequest,
934 ) -> Result<Page<Task>, SourceError> {
935 validate_page(page)?;
936 let value = self
937 .project_value(page.cursor.as_ref().map(|c| c.0.as_str()), page.limit)
938 .await?;
939 let id = required_str(&value, "id")?;
940 let items_connection = value.get("items").ok_or_else(|| SourceError::Malformed {
941 message: "GitHub project response is missing items".into(),
942 })?;
943 let nodes = items_connection
944 .get("nodes")
945 .and_then(Value::as_array)
946 .ok_or_else(|| SourceError::Malformed {
947 message: "GitHub project items.nodes is not an array".into(),
948 })?;
949 let items = nodes
950 .iter()
951 .map(|item| self.task(id, item))
952 .collect::<Result<Vec<_>, SourceError>>()?
953 .into_iter()
954 .flatten()
955 .collect();
956 let next = next_cursor(items_connection)?;
957 if let Some(next) = &next {
958 validate_cursor_progress(
959 page.cursor.as_ref().map(|cursor| cursor.0.as_str()),
960 &next.0,
961 )?;
962 }
963 Ok(Page { items, next })
964 }
965 async fn query_projects(
966 &self,
967 _query: &ProjectQuery,
968 page: &PageRequest,
969 ) -> Result<Page<Project>, SourceError> {
970 validate_page(page)?;
971 if page.cursor.is_some() {
972 return Err(SourceError::Config {
973 message: "GitHub project listing does not issue page cursors".into(),
974 });
975 }
976 Ok(Page::last(vec![
977 self.project(&self.project_value(None, 1).await?)?,
978 ]))
979 }
980 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
981 validate_page(page)?;
982 let offset = numeric_cursor(page.cursor.as_ref())?;
983 let mut labels = self
984 .all_tasks()
985 .await?
986 .into_iter()
987 .flat_map(|t| t.labels)
988 .fold(Vec::new(), |mut all, label| {
989 if !all.iter().any(|x: &Label| x.id == label.id) {
990 all.push(label);
991 }
992 all
993 });
994 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
995 Ok(offset_page(
996 labels,
997 offset,
998 page.limit.min(MAX_PAGE_SIZE) as usize,
999 ))
1000 }
1001 async fn task_dependencies(
1002 &self,
1003 id: &NativeId,
1004 direction: Direction,
1005 page: &PageRequest,
1006 ) -> Result<Page<DependencyEdge>, SourceError> {
1007 self.dependencies(id, direction, page).await
1008 }
1009 async fn project_dependencies(
1010 &self,
1011 id: &NativeId,
1012 direction: Direction,
1013 page: &PageRequest,
1014 ) -> Result<Page<DependencyEdge>, SourceError> {
1015 validate_page(page)?;
1016 let project = self.project_value(None, 1).await?;
1017 if required_str(&project, "id")? != id.0 {
1018 return Err(SourceError::Refused {
1019 message: format!("GitHub project {} was not found", id.0),
1020 });
1021 }
1022 let mut edges = Vec::new();
1023 for task in self.all_tasks().await? {
1024 let mut cursor = None;
1025 loop {
1026 let data = self.graphql(graphql::PROJECT_DEPENDENCIES, json!({"id":task.id.0,"first":MAX_PAGE_SIZE,"after":cursor.as_ref().map(|cursor: &Cursor| cursor.0.as_str()),"nestedFirst":MAX_PAGE_SIZE})).await?;
1027 let connection_name = match direction {
1028 Direction::DependsOn => "blockedBy",
1029 Direction::DependedOnBy => "blocking",
1030 };
1031 let Some(connection) = data.pointer(&format!("/node/{connection_name}")) else {
1032 break;
1035 };
1036 let related_issues = connection
1037 .get("nodes")
1038 .and_then(Value::as_array)
1039 .ok_or_else(|| SourceError::Malformed {
1040 message: "GitHub project dependency nodes is not an array".into(),
1041 })?;
1042 for related_issue in related_issues {
1043 for related in self.related_issue_projects(related_issue).await? {
1044 if related != *id {
1045 let (from, to) = match direction {
1047 Direction::DependsOn => (id.clone(), related),
1048 Direction::DependedOnBy => (related, id.clone()),
1049 };
1050 edges.push(DependencyEdge {
1051 from: DependencyEndpoint::from_native(from, ItemKind::Project),
1052 to: DependencyEndpoint::from_native(to, ItemKind::Project),
1053 kind: DependencyKind::Blocks,
1054 });
1055 }
1056 }
1057 }
1058 let next = next_cursor(connection)?;
1059 if let Some(next) = &next {
1060 validate_cursor_progress(
1061 cursor.as_ref().map(|value: &Cursor| value.0.as_str()),
1062 &next.0,
1063 )?;
1064 }
1065 cursor = next;
1066 if cursor.is_none() {
1067 break;
1068 }
1069 }
1070 }
1071 if direction == Direction::DependsOn {
1072 edges.extend(
1076 DependencyEdge::recorded(
1077 &self.project(&project)?.metadata,
1078 id,
1079 ItemKind::Project,
1080 &self.name,
1081 Some(ItemKind::Project),
1082 )
1083 .map_err(|message| SourceError::Malformed { message })?,
1084 );
1085 }
1086 let offset = numeric_cursor(page.cursor.as_ref())?;
1087 Ok(offset_page(edges, offset, page.limit as usize))
1088 }
1089
1090 fn writes(&self) -> WriteSupport {
1091 WriteSupport::Supported
1092 }
1093
1094 async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1095 let (project, existing) = self.board_and_item(write.target.as_ref()).await?;
1096 let project_id = required_str(&project, "id")?;
1097 let metadata_field =
1098 Self::field(&project, METADATA_FIELD)?.ok_or_else(|| SourceError::Refused {
1099 message: format!("GitHub project has no source-owned {METADATA_FIELD} text field"),
1100 })?;
1101 if required_str(metadata_field, "__typename")? != "ProjectV2Field" {
1102 return Err(SourceError::Refused {
1103 message: format!(
1104 "GitHub project source-owned {METADATA_FIELD} field is not a text field"
1105 ),
1106 });
1107 }
1108 let status_selection =
1109 Some(
1110 Self::field(&project, "Status")?.ok_or_else(|| SourceError::Refused {
1111 message: "GitHub project has no Status field".into(),
1112 })?,
1113 )
1114 .map(|field| {
1115 if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
1116 return Err(SourceError::Refused {
1117 message: "GitHub project Status field is not a single-select field".into(),
1118 });
1119 }
1120 let option = field
1121 .get("options")
1122 .and_then(Value::as_array)
1123 .and_then(|options| {
1124 options.iter().find(|option| {
1125 option
1126 .get("name")
1127 .and_then(Value::as_str)
1128 .is_some_and(|name| {
1129 name.eq_ignore_ascii_case(&write.item.status.name)
1130 })
1131 })
1132 })
1133 .ok_or_else(|| SourceError::Refused {
1134 message: format!(
1135 "GitHub Status field cannot represent status {}",
1136 write.item.status.name
1137 ),
1138 })?;
1139 Ok::<_, SourceError>((
1140 required_str(field, "id")?.to_owned(),
1141 required_str(option, "id")?.to_owned(),
1142 ))
1143 })
1144 .transpose()?;
1145 let (content_id, item_id, content_kind) = if let Some(target) = &write.target {
1146 let item = existing.ok_or_else(|| SourceError::Refused {
1147 message: format!("GitHub destination item {} was not found", target.0),
1148 })?;
1149 let content_kind = ContentKind::parse(item.get("content").unwrap_or(&Value::Null))?;
1150 if content_kind == ContentKind::DraftIssue && !write.item.labels.is_empty() {
1151 return Err(SourceError::Refused {
1152 message: "GitHub draft items cannot represent labels".into(),
1153 });
1154 }
1155 if content_kind == ContentKind::Issue {
1156 let held = Self::labels(&item)?;
1157 if held != write.item.labels {
1158 return Err(SourceError::Refused {
1159 message: "GitHub issue labels differ from the labels being written".into(),
1160 });
1161 }
1162 let native = item
1163 .pointer("/content/repository/nameWithOwner")
1164 .and_then(Value::as_str)
1165 .map(|value| format!("github.com/{value}"));
1166 if write
1167 .item
1168 .repositories
1169 .iter()
1170 .map(Repository::as_str)
1171 .collect::<Vec<_>>()
1172 != native.iter().map(String::as_str).collect::<Vec<_>>()
1173 {
1174 return Err(SourceError::Refused {
1175 message:
1176 "GitHub issue repository differs from the repositories being written"
1177 .into(),
1178 });
1179 }
1180 }
1181 let operation = match content_kind {
1182 ContentKind::DraftIssue => graphql::UPDATE_DRAFT,
1183 ContentKind::Issue => graphql::UPDATE_ISSUE,
1184 };
1185 let input = if content_kind == ContentKind::DraftIssue {
1186 json!({"draftIssueId":target.0,"title":write.item.title,"body":write.item.content})
1187 } else {
1188 json!({"id":target.0,"title":write.item.title,"body":write.item.content})
1189 };
1190 let data = self.graphql(operation, json!({"input":input})).await?;
1191 let pointer = if content_kind == ContentKind::DraftIssue {
1192 "/updateProjectV2DraftIssue/draftIssue"
1193 } else {
1194 "/updateIssue/issue"
1195 };
1196 let returned = data
1197 .pointer(pointer)
1198 .ok_or_else(|| SourceError::Malformed {
1199 message: "GitHub item update returned no item".into(),
1200 })?;
1201 if required_str(returned, "id")? != target.0 {
1202 return Err(SourceError::Malformed {
1203 message: "GitHub item update returned the wrong item".into(),
1204 });
1205 }
1206 (
1207 target.clone(),
1208 NativeId(required_str(&item, "id")?.into()),
1209 content_kind,
1210 )
1211 } else {
1212 if !write.item.labels.is_empty() {
1213 return Err(SourceError::Refused {
1214 message: "GitHub draft items cannot represent labels".into(),
1215 });
1216 }
1217 let data = self
1218 .graphql(
1219 graphql::CREATE_DRAFT,
1220 json!({"input":{
1221 "projectId":project_id,"title":write.item.title,"body":write.item.content
1222 }}),
1223 )
1224 .await?;
1225 let created = data
1226 .pointer("/addProjectV2DraftIssue/projectItem")
1227 .ok_or_else(|| SourceError::Malformed {
1228 message: "GitHub draft creation returned no project item".into(),
1229 })?;
1230 (
1231 NativeId(
1232 required_str(created.pointer("/content").unwrap_or(&Value::Null), "id")?.into(),
1233 ),
1234 NativeId(required_str(created, "id")?.into()),
1235 ContentKind::DraftIssue,
1236 )
1237 };
1238
1239 let mut fallback = Vec::new();
1240 let mut native = Vec::new();
1241 for edge in &write.depends_on {
1242 let same_source = edge
1243 .to
1244 .source()
1245 .is_none_or(|source| source == self.name.as_str());
1246 let far_id = edge
1247 .to
1248 .id()
1249 .rsplit_once(':')
1250 .map_or(edge.to.id(), |(_, id)| id);
1251 let far_issue = if same_source {
1252 let far = self
1253 .board_and_item(Some(&NativeId(far_id.into())))
1254 .await?
1255 .1
1256 .ok_or_else(|| SourceError::Refused {
1257 message: format!("GitHub dependency item {far_id} was not found"),
1258 })?;
1259 match required_str(far.get("content").unwrap_or(&Value::Null), "__typename")? {
1260 "Issue" => true,
1261 "DraftIssue" | "PullRequest" => false,
1262 other => {
1263 return Err(SourceError::Malformed {
1264 message: format!(
1265 "GitHub dependency item has unknown content type {other}"
1266 ),
1267 });
1268 }
1269 }
1270 } else {
1271 false
1272 };
1273 if content_kind == ContentKind::Issue && far_issue && edge.to.kind == ItemKind::Task {
1274 native.push(far_id.to_owned());
1275 } else {
1276 fallback.push(edge.clone());
1277 }
1278 }
1279 if content_kind == ContentKind::Issue {
1280 let current = self.native_dependency_ids(&content_id).await?;
1281 for (operation, far_id) in current
1282 .iter()
1283 .filter(|id| !native.contains(id))
1284 .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1285 .chain(
1286 native
1287 .iter()
1288 .filter(|id| !current.contains(id))
1289 .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1290 )
1291 {
1292 let data = self
1293 .graphql(
1294 operation,
1295 json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1296 )
1297 .await?;
1298 let root = if operation == graphql::ADD_BLOCKED_BY {
1299 "addBlockedBy"
1300 } else {
1301 "removeBlockedBy"
1302 };
1303 let issue = data.pointer(&format!("/{root}/issue")).ok_or_else(|| {
1304 SourceError::Malformed {
1305 message: "GitHub dependency update returned no issue".into(),
1306 }
1307 })?;
1308 let blocker = data
1309 .pointer(&format!("/{root}/blockingIssue"))
1310 .ok_or_else(|| SourceError::Malformed {
1311 message: "GitHub dependency update returned no blocking issue".into(),
1312 })?;
1313 if required_str(issue, "id")? != content_id.0
1314 || required_str(blocker, "id")? != far_id
1315 {
1316 return Err(SourceError::Malformed {
1317 message: "GitHub dependency update returned the wrong issues".into(),
1318 });
1319 }
1320 }
1321 }
1322 let metadata_write = ItemWrite {
1323 target: write.target.clone(),
1324 item: write.item.clone(),
1325 depends_on: fallback,
1326 };
1327 let storage = if content_kind == ContentKind::Issue {
1328 RepositoryStorage::Native
1329 } else {
1330 RepositoryStorage::Recorded
1331 };
1332 let metadata = Self::task_metadata(&metadata_write, storage)?;
1333 self.set_item_field(
1334 project_id,
1335 &item_id.0,
1336 required_str(metadata_field, "id")?,
1337 json!({"text":Value::Object(metadata.clone().into_iter().collect()).to_string()}),
1338 )
1339 .await?;
1340
1341 if let Some((field_id, option_id)) = status_selection {
1342 self.set_item_field(
1343 project_id,
1344 &item_id.0,
1345 &field_id,
1346 json!({"singleSelectOptionId":option_id}),
1347 )
1348 .await?;
1349 }
1350 Ok(content_id)
1351 }
1352
1353 async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1354 let project = self.project_value(None, 1).await?;
1355 let id = NativeId(required_str(&project, "id")?.into());
1356 if write.target.as_ref().is_some_and(|target| target != &id) {
1357 return Err(SourceError::Refused {
1358 message: format!(
1359 "GitHub project {} was not found",
1360 write.target.as_ref().unwrap().0
1361 ),
1362 });
1363 }
1364 if !write.item.labels.is_empty() {
1365 return Err(SourceError::Refused {
1366 message: "GitHub Projects v2 cannot represent project labels".into(),
1367 });
1368 }
1369 let mut metadata = write.item.metadata.clone();
1370 metadata.insert(
1371 Repository::METADATA_KEY.into(),
1372 Value::Array(
1373 write
1374 .item
1375 .repositories
1376 .iter()
1377 .map(|repository| Value::String(repository.as_str().to_owned()))
1378 .collect(),
1379 ),
1380 );
1381 metadata.insert(
1382 DependencyEdge::RECORDED_KEY.into(),
1383 Value::Array(
1384 write
1385 .depends_on
1386 .iter()
1387 .map(|edge| endpoint_value(&edge.to))
1388 .collect(),
1389 ),
1390 );
1391 let description = project_metadata_description(write.item.content.as_deref(), &metadata)?;
1392 let data = self
1393 .graphql(
1394 graphql::UPDATE_PROJECT,
1395 json!({"input":{
1396 "projectId":id.0,"title":write.item.title,"shortDescription":description,
1397 "closed":write.item.status.category == StatusCategory::Done
1398 }}),
1399 )
1400 .await?;
1401 let returned =
1402 data.pointer("/updateProjectV2/projectV2")
1403 .ok_or_else(|| SourceError::Malformed {
1404 message: "GitHub project update returned no project".into(),
1405 })?;
1406 if required_str(returned, "id")? != id.0 {
1407 return Err(SourceError::Malformed {
1408 message: "GitHub project update returned the wrong project".into(),
1409 });
1410 }
1411 Ok(id)
1412 }
1413}
1414
1415const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1418
1419fn recorded_offset(
1427 cursor: Option<&str>,
1428 direction: Direction,
1429) -> Result<Option<usize>, SourceError> {
1430 cursor
1431 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
1432 .map(|offset| {
1433 if direction != Direction::DependsOn {
1434 return Err(SourceError::Config {
1435 message: format!(
1436 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
1437 reverse dependency read never issues; resume it in the direction \
1438 that reported it"
1439 ),
1440 });
1441 }
1442 offset.parse().map_err(|_| SourceError::Config {
1443 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
1444 })
1445 })
1446 .transpose()
1447}
1448
1449fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1450 let mut page = offset_page(edges, offset, limit.max(1));
1451 page.next = page
1452 .next
1453 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
1454 page
1455}
1456
1457fn normalize_status_mapping(
1458 mapping: BTreeMap<String, StatusCategory>,
1459) -> Result<BTreeMap<StatusName, StatusCategory>, SourceError> {
1460 let mut normalized = BTreeMap::new();
1461 for (name, category) in mapping {
1462 if name.trim().is_empty() {
1463 return Err(SourceError::Config {
1464 message: "status_mapping contains a blank status name".into(),
1465 });
1466 }
1467 let key = StatusName::new(&name);
1468 if normalized.insert(key, category).is_some() {
1469 return Err(SourceError::Config {
1470 message: format!("status_mapping contains case-insensitive duplicate {name}"),
1471 });
1472 }
1473 }
1474 Ok(normalized)
1475}
1476
1477fn valid_github_owner(owner: &str) -> bool {
1478 !owner.is_empty()
1479 && owner.len() <= 39
1480 && !owner.starts_with('-')
1481 && !owner.ends_with('-')
1482 && !owner.contains("--")
1483 && owner
1484 .bytes()
1485 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1486}
1487
1488fn valid_environment_name(name: &str) -> bool {
1489 let mut bytes = name.bytes();
1490 bytes
1491 .next()
1492 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1493 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1497struct StatusName(String);
1498
1499impl StatusName {
1500 fn new(name: &str) -> Self {
1501 Self(name.to_lowercase())
1502 }
1503}
1504
1505fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
1506 value
1507 .get(field)
1508 .and_then(Value::as_str)
1509 .ok_or_else(|| SourceError::Malformed {
1510 message: format!("GitHub response is missing string field {field}"),
1511 })
1512}
1513
1514const METADATA_FIELD: &str = "onetaskgraph.metadata";
1515
1516fn endpoint_value(endpoint: &DependencyEndpoint) -> Value {
1517 json!({"id":endpoint.id(), "kind":match endpoint.kind { ItemKind::Task => "task", ItemKind::Project => "project" }})
1518}
1519
1520fn metadata_field(field_values: &Value) -> Result<BTreeMap<String, Value>, SourceError> {
1521 let nodes = field_values
1522 .get("nodes")
1523 .and_then(Value::as_array)
1524 .ok_or_else(|| SourceError::Malformed {
1525 message: "GitHub project item fieldValues.nodes is not an array".into(),
1526 })?;
1527 let Some(text) = nodes
1528 .iter()
1529 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(METADATA_FIELD))
1530 .and_then(|node| node.get("text"))
1531 else {
1532 return Ok(BTreeMap::new());
1533 };
1534 text.as_str()
1535 .ok_or_else(|| SourceError::Malformed {
1536 message: format!("GitHub {METADATA_FIELD} field text is not a string"),
1537 })
1538 .and_then(|text| {
1539 serde_json::from_str(text).map_err(|error| SourceError::Malformed {
1540 message: format!(
1541 "GitHub {METADATA_FIELD} field is not canonical JSON metadata: {error}"
1542 ),
1543 })
1544 })
1545}
1546
1547fn repositories(content: &Value, field_values: &Value) -> Result<Vec<Repository>, SourceError> {
1548 if let Some(origin) = content
1549 .pointer("/repository/nameWithOwner")
1550 .and_then(Value::as_str)
1551 {
1552 return Repository::try_from(format!("github.com/{origin}"))
1553 .map(|repository| vec![repository])
1554 .map_err(|message| SourceError::Malformed { message });
1555 }
1556 repositories_from_metadata(&metadata_field(field_values)?)
1557}
1558
1559const PROJECT_METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
1560const PROJECT_METADATA_CLOSE: &str = "\n-->";
1561
1562fn metadata_description(
1563 description: Option<String>,
1564) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
1565 let Some(description) = description else {
1566 return Ok((None, BTreeMap::new()));
1567 };
1568 let Some(start) = description.rfind(PROJECT_METADATA_OPEN) else {
1569 return Ok((Some(description), BTreeMap::new()));
1570 };
1571 let value_start = start + PROJECT_METADATA_OPEN.len();
1572 let Some(relative_end) = description[value_start..].find(PROJECT_METADATA_CLOSE) else {
1573 return Err(SourceError::Malformed {
1574 message: "unterminated onetaskgraph metadata slot in GitHub project description".into(),
1575 });
1576 };
1577 let value_end = value_start + relative_end;
1578 if !description[value_end + PROJECT_METADATA_CLOSE.len()..]
1579 .trim()
1580 .is_empty()
1581 {
1582 return Ok((Some(description), BTreeMap::new()));
1583 }
1584 let metadata = serde_json::from_str(&description[value_start..value_end]).map_err(|error| {
1585 SourceError::Malformed {
1586 message: format!("invalid canonical JSON in GitHub project metadata slot: {error}"),
1587 }
1588 })?;
1589 let visible = description[..start].trim_end();
1590 Ok(((!visible.is_empty()).then(|| visible.into()), metadata))
1591}
1592
1593fn project_metadata_description(
1594 content: Option<&str>,
1595 metadata: &BTreeMap<String, Value>,
1596) -> Result<Option<String>, SourceError> {
1597 if metadata.is_empty() {
1598 return Ok(content.filter(|value| !value.is_empty()).map(str::to_owned));
1599 }
1600 let encoded = Value::Object(metadata.clone().into_iter().collect()).to_string();
1601 Ok(Some(format!(
1602 "{}{}{}\n{}",
1603 content.unwrap_or_default(),
1604 if content.is_some_and(|value| !value.is_empty()) {
1605 "\n\n"
1606 } else {
1607 ""
1608 },
1609 PROJECT_METADATA_OPEN,
1610 format_args!("{encoded}\n-->")
1611 )))
1612}
1613
1614fn repositories_from_metadata(
1615 metadata: &BTreeMap<String, Value>,
1616) -> Result<Vec<Repository>, SourceError> {
1617 Repository::from_metadata(metadata).map_err(|message| SourceError::Malformed { message })
1618}
1619fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
1620 value
1621 .get(field)
1622 .and_then(Value::as_bool)
1623 .ok_or_else(|| SourceError::Malformed {
1624 message: format!("GitHub response is missing boolean field {field}"),
1625 })
1626}
1627fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
1628 match value.get(field) {
1629 None | Some(Value::Null) => Ok(None),
1630 Some(value) => value
1631 .as_str()
1632 .map(Some)
1633 .ok_or_else(|| SourceError::Malformed {
1634 message: format!("GitHub response field {field} is not a string or null"),
1635 }),
1636 }
1637}
1638fn optional_nodes<'a>(
1639 connection: Option<&'a Value>,
1640 name: &str,
1641) -> Result<Option<&'a Vec<Value>>, SourceError> {
1642 match connection {
1643 None | Some(Value::Null) => Ok(None),
1644 Some(value) => value
1645 .get("nodes")
1646 .and_then(Value::as_array)
1647 .map(Some)
1648 .ok_or_else(|| SourceError::Malformed {
1649 message: format!("GitHub {name}.nodes is not an array"),
1650 }),
1651 }
1652}
1653fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
1654 let page_info = connection
1655 .get("pageInfo")
1656 .ok_or_else(|| SourceError::Malformed {
1657 message: format!("GitHub {name} has no pageInfo"),
1658 })?;
1659 if required_bool(page_info, "hasNextPage")? {
1660 return Err(SourceError::Malformed {
1661 message: format!(
1662 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
1663 ),
1664 });
1665 }
1666 Ok(())
1667}
1668fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1669 optional_str(value, field)?
1670 .map(|timestamp| {
1671 timestamp.parse().map_err(|error| SourceError::Malformed {
1672 message: format!("GitHub response field {field} is not a timestamp: {error}"),
1673 })
1674 })
1675 .transpose()
1676}
1677fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
1678 if page.limit == 0 {
1679 Err(SourceError::Config {
1680 message: "page limit must be at least 1".into(),
1681 })
1682 } else {
1683 Ok(())
1684 }
1685}
1686fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
1687 let page = connection
1688 .get("pageInfo")
1689 .filter(|value| value.is_object())
1690 .ok_or_else(|| SourceError::Malformed {
1691 message: "GitHub connection is missing pageInfo".into(),
1692 })?;
1693 if required_bool(page, "hasNextPage")? {
1694 let cursor = required_str(page, "endCursor")?;
1695 validate_cursor_progress(None, cursor)?;
1696 Ok(Some(Cursor(cursor.into())))
1697 } else {
1698 Ok(None)
1699 }
1700}
1701fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
1702 if next.is_empty() || previous == Some(next) {
1703 Err(SourceError::Malformed {
1704 message: "GitHub pagination cursor is empty or did not advance".into(),
1705 })
1706 } else {
1707 Ok(())
1708 }
1709}
1710fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
1711 cursor.map_or(Ok(0), |c| {
1712 c.0.parse().map_err(|_| SourceError::Config {
1713 message: "label cursor is invalid".into(),
1714 })
1715 })
1716}
1717fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
1718 if offset > items.len() {
1719 return Page::last(vec![]);
1720 }
1721 let tail = items.split_off(offset);
1722 let mut selected = tail;
1723 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
1724 selected.truncate(limit);
1725 Page {
1726 items: selected,
1727 next,
1728 }
1729}