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