1#![deny(missing_docs)]
26
27use std::collections::BTreeMap;
28
29use chrono::{DateTime, Utc};
30use onetaskgraph_plugin_api::{
31 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
32 Direction, Health, ItemKind, Label, NativeId, Page, PageRequest, Project, ProjectQuery,
33 Repository, SecretResolver, SourceError, SourceName, SourcePlugin, Status, StatusCategory,
34 Support, Task, TaskQuery, TaskSource,
35};
36use reqwest::{Client, StatusCode, Url};
37use schemars::{Schema, schema_for};
38use secrecy::{ExposeSecret, SecretString};
39use serde::Deserialize;
40use serde_json::{Value, json};
41
42pub const KIND: &str = "github-projects";
44pub const MAX_PAGE_SIZE: u32 = 100;
46const NESTED_PAGE_SIZE: u32 = 50;
48
49pub mod graphql {
54 pub const PROJECT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){
56 owner:repositoryOwner(login:$owner){
57 ... on ProjectV2Owner{projectV2(number:$number){...Project}}
58 }
59 } fragment Project on ProjectV2 { id title shortDescription url createdAt updatedAt closed
60 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
61 ... on ProjectV2ItemFieldSingleSelectValue{name field{
62 ... on ProjectV2SingleSelectField{name}
63 }}
64 ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{name}}}
65 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
66 }pageInfo{hasNextPage}} content{
67 ... on Issue{id title body url createdAt updatedAt state repository{nameWithOwner} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
68 ... on PullRequest{id title body url createdAt updatedAt state repository{nameWithOwner} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
69 ... on DraftIssue{id title body createdAt updatedAt}
70 }} pageInfo{hasNextPage endCursor}}
71 }"#;
72 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}}}}}"#;
74 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}}}}}"#;
76 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}}}}}"#;
78}
79
80fn default_token_env() -> String {
81 "GH_PROJECTS_TOKEN".to_owned()
82}
83fn default_endpoint() -> String {
84 "https://api.github.com/graphql".to_owned()
85}
86
87#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
89#[serde(default, deny_unknown_fields)]
90pub struct GitHubProjectsConfig {
91 pub owner: String, pub project_number: u32, #[serde(default = "default_token_env")]
97 pub token_env: String, #[serde(default = "default_endpoint")]
100 pub endpoint: String, #[serde(default)]
103 pub status_mapping: BTreeMap<String, StatusCategory>, }
105
106#[derive(Debug, Clone, Copy, Default)]
108pub struct Plugin;
109
110impl SourcePlugin for Plugin {
111 fn kind(&self) -> &'static str {
112 KIND
113 }
114 fn config_schema(&self) -> Schema {
115 schema_for!(GitHubProjectsConfig)
116 }
117 fn build(
118 &self,
119 name: &SourceName,
120 config: &Value,
121 secrets: &dyn SecretResolver,
122 ) -> Result<Box<dyn TaskSource>, SourceError> {
123 let config: GitHubProjectsConfig =
124 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
125 message: format!("source {name}: {e}"),
126 })?;
127 let source =
128 GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
129 SourceError::Config { message } => SourceError::Config {
130 message: format!("source {name}: {message}"),
131 },
132 SourceError::Auth { message } => SourceError::Auth {
133 message: format!("source {name}: {message}"),
134 },
135 other => other,
136 })?;
137 Ok(Box::new(source))
138 }
139}
140
141pub struct GitHubProjectsSource {
143 name: SourceName,
146 owner: String, project_number: u32, endpoint: Url,
149 token: SecretString,
150 credential_name: String, statuses: BTreeMap<StatusName, StatusCategory>,
152 client: Client,
153}
154
155impl GitHubProjectsSource {
156 pub fn new(
162 name: &SourceName,
163 config: GitHubProjectsConfig,
164 secrets: &dyn SecretResolver,
165 ) -> Result<Self, SourceError> {
166 if !valid_github_owner(&config.owner) {
167 return Err(SourceError::Config {
168 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
169 });
170 }
171 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
172 return Err(SourceError::Config {
173 message: format!("project_number must be between 1 and {}", i32::MAX),
174 });
175 }
176 if !valid_environment_name(&config.token_env) {
177 return Err(SourceError::Config {
178 message: "token_env must be a valid environment-variable name".into(),
179 });
180 }
181 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
182 message: format!("endpoint is not a valid URL: {e}"),
183 })?;
184 if endpoint.scheme() != "https"
185 && !(endpoint.scheme() == "http"
186 && endpoint
187 .host_str()
188 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
189 {
190 return Err(SourceError::Config {
191 message:
192 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
193 .into(),
194 });
195 }
196 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
197 message: format!("environment variable {} is missing or empty; set it to a GitHub token with read:project and repository Issues read access", config.token_env),
198 })?;
199 Ok(Self {
200 name: name.clone(),
201 owner: config.owner,
202 project_number: config.project_number,
203 endpoint,
204 token,
205 credential_name: config.token_env,
206 statuses: normalize_status_mapping(config.status_mapping)?,
207 client: Client::builder()
208 .user_agent("onetaskgraph")
209 .build()
210 .map_err(|e| SourceError::Config {
211 message: format!("cannot build HTTP client: {e}"),
212 })?,
213 })
214 }
215
216 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
217 let response = self
218 .client
219 .post(self.endpoint.clone())
220 .bearer_auth(self.token.expose_secret())
221 .json(&json!({"query": query, "variables": variables}))
222 .send()
223 .await
224 .map_err(|e| SourceError::Unavailable {
225 message: format!("GitHub GraphQL request failed: {e}"),
226 })?;
227 let status = response.status();
228 let retry_after = response
229 .headers()
230 .get("retry-after")
231 .and_then(|v| v.to_str().ok())
232 .and_then(|v| v.parse().ok());
233 let exhausted = response
234 .headers()
235 .get("x-ratelimit-remaining")
236 .and_then(|v| v.to_str().ok())
237 == Some("0");
238 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
239 return Err(SourceError::RateLimited {
240 retry_after_seconds: retry_after,
241 });
242 }
243 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
244 return Err(SourceError::Auth {
245 message: format!(
246 "GitHub rejected the configured credential with HTTP {status}; grant it read:project and repository Issues read access"
247 ),
248 });
249 }
250 if !status.is_success() {
251 return Err(SourceError::Unavailable {
252 message: format!("GitHub GraphQL returned HTTP {status}"),
253 });
254 }
255 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
256 message: format!("GitHub returned invalid JSON: {e}"),
257 })?;
258 let errors = body
259 .get("errors")
260 .map(|value| {
261 value.as_array().ok_or_else(|| SourceError::Malformed {
262 message: "GitHub response errors is not an array".into(),
263 })
264 })
265 .transpose()?;
266 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
267 let messages = errors
268 .iter()
269 .filter_map(|e| e.get("message").and_then(Value::as_str))
270 .collect::<Vec<_>>()
271 .join("; ");
272 let message = if messages.is_empty() {
273 "GitHub returned GraphQL errors".into()
274 } else {
275 messages
276 };
277 let normalized = message.to_ascii_lowercase();
278 if normalized.contains("resource not accessible") || normalized.contains("scope") {
279 return Err(SourceError::Auth {
280 message: format!(
281 "{message}; grant {} read:project and repository Issues read access",
282 self.credential_name
283 ),
284 });
285 }
286 return Err(SourceError::Refused { message });
287 }
288 body.get("data")
289 .filter(|data| data.is_object())
290 .cloned()
291 .ok_or_else(|| SourceError::Malformed {
292 message: "GitHub response has no data object".into(),
293 })
294 }
295
296 async fn project_value(
300 &self,
301 items_after: Option<&str>,
302 items_first: u32,
303 ) -> Result<Value, SourceError> {
304 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?;
305 data.pointer("/owner/projectV2")
306 .filter(|v| !v.is_null())
307 .cloned()
308 .ok_or_else(|| SourceError::Refused {
309 message: format!(
310 "GitHub project {}/{} was not found or is not visible to the token",
311 self.owner, self.project_number
312 ),
313 })
314 }
315
316 fn status(&self, item: &Value) -> Result<Status, SourceError> {
317 let fields = item
318 .pointer("/fieldValues/nodes")
319 .and_then(Value::as_array)
320 .expect("task validates fieldValues.nodes before mapping status");
321 let name = fields
322 .iter()
323 .find(|v| v.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
324 .map(|value| required_str(value, "name"))
325 .transpose()?
326 .or(optional_str(
327 item.get("content").unwrap_or(&Value::Null),
328 "state",
329 )?)
330 .unwrap_or("Unknown")
331 .to_owned();
332 let category = self
333 .statuses
334 .get(&StatusName::new(&name))
335 .copied()
336 .unwrap_or_else(|| match name.to_ascii_lowercase().as_str() {
337 "backlog" => StatusCategory::Backlog,
338 "todo" | "open" => StatusCategory::Todo,
339 "in progress" | "in review" => StatusCategory::InProgress,
340 "done" | "closed" | "merged" => StatusCategory::Done,
341 "cancelled" | "canceled" => StatusCategory::Cancelled,
342 _ => StatusCategory::Unknown,
343 });
344 Ok(Status { category, name })
345 }
346
347 fn labels(item: &Value) -> Result<Vec<Label>, SourceError> {
348 let direct = optional_nodes(item.pointer("/content/labels"), "content labels")?;
349 let field_values = item
350 .pointer("/fieldValues/nodes")
351 .and_then(Value::as_array)
352 .expect("task validates fieldValues.nodes before mapping labels");
353 let field = field_values
354 .iter()
355 .find_map(|value| value.get("labels"))
356 .map(|labels| optional_nodes(Some(labels), "field labels"))
357 .transpose()?
358 .flatten();
359 let labels = direct
360 .into_iter()
361 .flatten()
362 .chain(field.into_iter().flatten())
363 .map(|v| {
364 Ok(Label {
365 id: NativeId(required_str(v, "id")?.to_owned()),
366 name: required_str(v, "name")?.to_owned(),
367 color: optional_str(v, "color")?.map(str::to_owned),
368 })
369 })
370 .collect::<Result<Vec<_>, SourceError>>()?
371 .into_iter()
372 .fold(Vec::new(), |mut labels, label| {
373 if !labels.iter().any(|x: &Label| x.id == label.id) {
374 labels.push(label);
375 }
376 labels
377 });
378 Ok(labels)
379 }
380
381 fn task(&self, project_id: &str, item: &Value) -> Result<Option<Task>, SourceError> {
382 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
383 message: "GitHub project item is missing content".into(),
384 })?;
385 if content.is_null() {
386 return Ok(None);
387 }
388 let field_values = item
389 .get("fieldValues")
390 .ok_or_else(|| SourceError::Malformed {
391 message: "GitHub project item is missing fieldValues".into(),
392 })?;
393 complete_connection(field_values, "project item field values")?;
394 field_values
395 .get("nodes")
396 .and_then(Value::as_array)
397 .ok_or_else(|| SourceError::Malformed {
398 message: "GitHub project item fieldValues.nodes is not an array".into(),
399 })?;
400 if let Some(labels) = content.get("labels") {
401 complete_connection(labels, "content labels")?;
402 }
403 for field_value in field_values["nodes"].as_array().expect("validated above") {
404 if let Some(labels) = field_value.get("labels") {
405 complete_connection(labels, "project item field labels")?;
406 }
407 }
408 Ok(Some(Task {
409 id: NativeId(required_str(content, "id")?.to_owned()),
410 title: required_str(content, "title")?.to_owned(),
411 content: optional_str(content, "body")?
412 .filter(|s| !s.is_empty())
413 .map(str::to_owned),
414 status: self.status(item)?,
415 labels: Self::labels(item)?,
416 project: Some(NativeId(project_id.to_owned())),
417 url: optional_str(content, "url")?.map(str::to_owned),
418 created_at: optional_time(content, "createdAt")?,
419 updated_at: optional_time(content, "updatedAt")?,
420 metadata: metadata_field(field_values)?,
421 repositories: repositories(content, field_values)?,
422 }))
423 }
424
425 fn project(&self, value: &Value) -> Result<Project, SourceError> {
426 let id = required_str(value, "id")?;
427 let (content, metadata) =
428 metadata_description(optional_str(value, "shortDescription")?.map(str::to_owned))?;
429 let repositories = repositories_from_metadata(&metadata)?;
430 Ok(Project {
431 id: NativeId(id.into()),
432 title: required_str(value, "title")?.into(),
433 content,
434 status: Status {
435 category: if required_bool(value, "closed")? {
436 StatusCategory::Done
437 } else {
438 StatusCategory::InProgress
439 },
440 name: if required_bool(value, "closed")? {
441 "Closed"
442 } else {
443 "Open"
444 }
445 .into(),
446 },
447 labels: vec![],
448 url: optional_str(value, "url")?.map(str::to_owned),
449 created_at: optional_time(value, "createdAt")?,
450 updated_at: optional_time(value, "updatedAt")?,
451 metadata,
452 repositories,
453 })
454 }
455
456 async fn all_tasks(&self) -> Result<Vec<Task>, SourceError> {
457 let mut after = None;
458 let mut tasks = Vec::new();
459 loop {
460 let project = self.project_value(after.as_deref(), MAX_PAGE_SIZE).await?;
461 let project_id = required_str(&project, "id")?;
462 let items = project
463 .pointer("/items/nodes")
464 .and_then(Value::as_array)
465 .ok_or_else(|| SourceError::Malformed {
466 message: "GitHub project items.nodes is not an array".into(),
467 })?;
468 for item in items {
469 if let Some(task) = self.task(project_id, item)? {
470 tasks.push(task);
471 }
472 }
473 let page =
474 project
475 .pointer("/items/pageInfo")
476 .ok_or_else(|| SourceError::Malformed {
477 message: "GitHub project items have no pageInfo".into(),
478 })?;
479 if !required_bool(page, "hasNextPage")? {
480 break;
481 }
482 let next = required_str(page, "endCursor")?;
483 validate_cursor_progress(after.as_deref(), next)?;
484 after = Some(next.to_owned());
485 }
486 Ok(tasks)
487 }
488
489 async fn dependencies(
490 &self,
491 id: &NativeId,
492 direction: Direction,
493 page: &PageRequest,
494 ) -> Result<Page<DependencyEdge>, SourceError> {
495 validate_page(page)?;
496 let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
497 let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
498 let recorded = recorded_offset(cursor, direction)?;
499 let data = self
504 .graphql(
505 graphql::TASK_DEPENDENCIES,
506 json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
507 "after":if recorded.is_some() {None} else {cursor}}),
508 )
509 .await?;
510 let node =
511 data.get("node")
512 .filter(|v| !v.is_null())
513 .ok_or_else(|| SourceError::Refused {
514 message: format!(
515 "GitHub item {} was not found or does not support dependencies",
516 id.0
517 ),
518 })?;
519 let connection_name = match direction {
520 Direction::DependsOn => "blockedBy",
521 Direction::DependedOnBy => "blocking",
522 };
523 let natively_names =
527 (required_str(node, "__typename")? == "Issue").then_some(ItemKind::Task);
528 if let Some(offset) = recorded {
529 return Ok(recorded_page(
530 self.recorded_task_edges(id, direction, natively_names)
531 .await?,
532 offset,
533 limit,
534 ));
535 }
536 if natively_names.is_none() {
537 return Ok(recorded_page(
538 self.recorded_task_edges(id, direction, natively_names)
539 .await?,
540 0,
541 limit,
542 ));
543 }
544 let connection = node
545 .get(connection_name)
546 .ok_or_else(|| SourceError::Malformed {
547 message: "GitHub dependency response is missing its connection".into(),
548 })?;
549 let nodes = connection
550 .get("nodes")
551 .and_then(Value::as_array)
552 .ok_or_else(|| SourceError::Malformed {
553 message: "GitHub dependency response nodes is not an array".into(),
554 })?;
555 let items = nodes
559 .iter()
560 .map(|value| {
561 let related = NativeId(required_str(value, "id")?.into());
562 let (from, to) = match direction {
563 Direction::DependsOn => (id.clone(), related),
564 Direction::DependedOnBy => (related, id.clone()),
565 };
566 Ok(DependencyEdge {
567 from: DependencyEndpoint::from_native(from, ItemKind::Task),
568 to: DependencyEndpoint::from_native(to, ItemKind::Task),
569 kind: DependencyKind::Blocks,
570 })
571 })
572 .collect::<Result<Vec<_>, SourceError>>()?;
573 let mut next = next_cursor(connection)?;
574 if let Some(next) = &next {
575 validate_cursor_progress(cursor, &next.0)?;
576 }
577 if next.is_none()
578 && !self
579 .recorded_task_edges(id, direction, natively_names)
580 .await?
581 .is_empty()
582 {
583 next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
584 }
585 Ok(Page { items, next })
586 }
587
588 async fn recorded_task_edges(
598 &self,
599 id: &NativeId,
600 direction: Direction,
601 natively_names: Option<ItemKind>,
602 ) -> Result<Vec<DependencyEdge>, SourceError> {
603 if direction != Direction::DependsOn {
604 return Ok(Vec::new());
605 }
606 let Some(task) = self
607 .all_tasks()
608 .await?
609 .into_iter()
610 .find(|task| task.id == *id)
611 else {
612 return Ok(Vec::new());
613 };
614 DependencyEdge::recorded(
615 &task.metadata,
616 id,
617 ItemKind::Task,
618 &self.name,
619 natively_names,
620 )
621 .map_err(|message| SourceError::Malformed { message })
622 }
623
624 async fn related_issue_projects(&self, issue: &Value) -> Result<Vec<NativeId>, SourceError> {
625 let issue_id = required_str(issue, "id")?;
626 let mut connection =
627 issue
628 .get("projectItems")
629 .cloned()
630 .ok_or_else(|| SourceError::Malformed {
631 message: "GitHub related issue is missing projectItems".into(),
632 })?;
633 let mut projects = Vec::new();
634 let mut previous = None;
635 loop {
636 let nodes = connection
637 .get("nodes")
638 .and_then(Value::as_array)
639 .ok_or_else(|| SourceError::Malformed {
640 message: "GitHub related issue projectItems.nodes is not an array".into(),
641 })?;
642 for item in nodes {
643 projects.push(NativeId(
644 required_str(
645 item.get("project").ok_or_else(|| SourceError::Malformed {
646 message: "GitHub dependency project item has no project".into(),
647 })?,
648 "id",
649 )?
650 .into(),
651 ));
652 }
653 let Some(cursor) = next_cursor(&connection)? else {
654 break;
655 };
656 validate_cursor_progress(previous.as_deref(), &cursor.0)?;
657 previous = Some(cursor.0.clone());
658 let data = self
659 .graphql(
660 graphql::RELATED_PROJECTS,
661 json!({"id":issue_id,"first":MAX_PAGE_SIZE,"after":cursor.0}),
662 )
663 .await?;
664 connection = data.pointer("/node/projectItems").cloned().ok_or_else(|| {
665 SourceError::Malformed {
666 message: "GitHub related issue response is missing projectItems".into(),
667 }
668 })?;
669 }
670 Ok(projects)
671 }
672}
673
674#[async_trait::async_trait]
675impl TaskSource for GitHubProjectsSource {
676 fn kind(&self) -> &'static str {
677 KIND
678 }
679 fn capabilities(&self) -> Capabilities {
680 Capabilities {
681 projects: Support::Native,
682 orphan_tasks: Support::Unsupported,
683 filter_by_label: Support::Unsupported,
684 filter_by_status: Support::Unsupported,
685 search_title: Support::Unsupported,
686 search_content: Support::Unsupported,
687 task_dependencies: DependencySupport::BothDirections,
688 project_dependencies: DependencySupport::BothDirections,
689 max_page_size: MAX_PAGE_SIZE,
690 }
691 }
692 async fn health(&self) -> Result<Health, SourceError> {
693 let project = self.project_value(None, 1).await?;
694 Ok(Health {
695 reachable: true,
696 detail: Some(format!(
697 "reading GitHub project {}/{} ({})",
698 self.owner,
699 self.project_number,
700 required_str(&project, "title")?
701 )),
702 })
703 }
704 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
705 Ok(self
706 .all_tasks()
707 .await?
708 .into_iter()
709 .find(|task| task.id == *id))
710 }
711 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
712 let value = self.project_value(None, 1).await?;
713 let project = self.project(&value)?;
714 Ok((project.id == *id).then_some(project))
715 }
716 async fn query_tasks(
717 &self,
718 _query: &TaskQuery,
719 page: &PageRequest,
720 ) -> Result<Page<Task>, SourceError> {
721 validate_page(page)?;
722 let value = self
723 .project_value(page.cursor.as_ref().map(|c| c.0.as_str()), page.limit)
724 .await?;
725 let id = required_str(&value, "id")?;
726 let items_connection = value.get("items").ok_or_else(|| SourceError::Malformed {
727 message: "GitHub project response is missing items".into(),
728 })?;
729 let nodes = items_connection
730 .get("nodes")
731 .and_then(Value::as_array)
732 .ok_or_else(|| SourceError::Malformed {
733 message: "GitHub project items.nodes is not an array".into(),
734 })?;
735 let items = nodes
736 .iter()
737 .map(|item| self.task(id, item))
738 .collect::<Result<Vec<_>, SourceError>>()?
739 .into_iter()
740 .flatten()
741 .collect();
742 let next = next_cursor(items_connection)?;
743 if let Some(next) = &next {
744 validate_cursor_progress(
745 page.cursor.as_ref().map(|cursor| cursor.0.as_str()),
746 &next.0,
747 )?;
748 }
749 Ok(Page { items, next })
750 }
751 async fn query_projects(
752 &self,
753 _query: &ProjectQuery,
754 page: &PageRequest,
755 ) -> Result<Page<Project>, SourceError> {
756 validate_page(page)?;
757 if page.cursor.is_some() {
758 return Err(SourceError::Config {
759 message: "GitHub project listing does not issue page cursors".into(),
760 });
761 }
762 Ok(Page::last(vec![
763 self.project(&self.project_value(None, 1).await?)?,
764 ]))
765 }
766 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
767 validate_page(page)?;
768 let offset = numeric_cursor(page.cursor.as_ref())?;
769 let mut labels = self
770 .all_tasks()
771 .await?
772 .into_iter()
773 .flat_map(|t| t.labels)
774 .fold(Vec::new(), |mut all, label| {
775 if !all.iter().any(|x: &Label| x.id == label.id) {
776 all.push(label);
777 }
778 all
779 });
780 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
781 Ok(offset_page(
782 labels,
783 offset,
784 page.limit.min(MAX_PAGE_SIZE) as usize,
785 ))
786 }
787 async fn task_dependencies(
788 &self,
789 id: &NativeId,
790 direction: Direction,
791 page: &PageRequest,
792 ) -> Result<Page<DependencyEdge>, SourceError> {
793 self.dependencies(id, direction, page).await
794 }
795 async fn project_dependencies(
796 &self,
797 id: &NativeId,
798 direction: Direction,
799 page: &PageRequest,
800 ) -> Result<Page<DependencyEdge>, SourceError> {
801 validate_page(page)?;
802 let project = self.project_value(None, 1).await?;
803 if required_str(&project, "id")? != id.0 {
804 return Err(SourceError::Refused {
805 message: format!("GitHub project {} was not found", id.0),
806 });
807 }
808 let mut edges = Vec::new();
809 for task in self.all_tasks().await? {
810 let mut cursor = None;
811 loop {
812 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?;
813 let connection_name = match direction {
814 Direction::DependsOn => "blockedBy",
815 Direction::DependedOnBy => "blocking",
816 };
817 let Some(connection) = data.pointer(&format!("/node/{connection_name}")) else {
818 break;
821 };
822 let related_issues = connection
823 .get("nodes")
824 .and_then(Value::as_array)
825 .ok_or_else(|| SourceError::Malformed {
826 message: "GitHub project dependency nodes is not an array".into(),
827 })?;
828 for related_issue in related_issues {
829 for related in self.related_issue_projects(related_issue).await? {
830 if related != *id {
831 let (from, to) = match direction {
833 Direction::DependsOn => (id.clone(), related),
834 Direction::DependedOnBy => (related, id.clone()),
835 };
836 edges.push(DependencyEdge {
837 from: DependencyEndpoint::from_native(from, ItemKind::Project),
838 to: DependencyEndpoint::from_native(to, ItemKind::Project),
839 kind: DependencyKind::Blocks,
840 });
841 }
842 }
843 }
844 let next = next_cursor(connection)?;
845 if let Some(next) = &next {
846 validate_cursor_progress(
847 cursor.as_ref().map(|value: &Cursor| value.0.as_str()),
848 &next.0,
849 )?;
850 }
851 cursor = next;
852 if cursor.is_none() {
853 break;
854 }
855 }
856 }
857 if direction == Direction::DependsOn {
858 edges.extend(
862 DependencyEdge::recorded(
863 &self.project(&project)?.metadata,
864 id,
865 ItemKind::Project,
866 &self.name,
867 Some(ItemKind::Project),
868 )
869 .map_err(|message| SourceError::Malformed { message })?,
870 );
871 }
872 let offset = numeric_cursor(page.cursor.as_ref())?;
873 Ok(offset_page(edges, offset, page.limit as usize))
874 }
875}
876
877const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
880
881fn recorded_offset(
889 cursor: Option<&str>,
890 direction: Direction,
891) -> Result<Option<usize>, SourceError> {
892 cursor
893 .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
894 .map(|offset| {
895 if direction != Direction::DependsOn {
896 return Err(SourceError::Config {
897 message: format!(
898 "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
899 reverse dependency read never issues; resume it in the direction \
900 that reported it"
901 ),
902 });
903 }
904 offset.parse().map_err(|_| SourceError::Config {
905 message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
906 })
907 })
908 .transpose()
909}
910
911fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
912 let mut page = offset_page(edges, offset, limit.max(1));
913 page.next = page
914 .next
915 .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
916 page
917}
918
919fn normalize_status_mapping(
920 mapping: BTreeMap<String, StatusCategory>,
921) -> Result<BTreeMap<StatusName, StatusCategory>, SourceError> {
922 let mut normalized = BTreeMap::new();
923 for (name, category) in mapping {
924 if name.trim().is_empty() {
925 return Err(SourceError::Config {
926 message: "status_mapping contains a blank status name".into(),
927 });
928 }
929 let key = StatusName::new(&name);
930 if normalized.insert(key, category).is_some() {
931 return Err(SourceError::Config {
932 message: format!("status_mapping contains case-insensitive duplicate {name}"),
933 });
934 }
935 }
936 Ok(normalized)
937}
938
939fn valid_github_owner(owner: &str) -> bool {
940 !owner.is_empty()
941 && owner.len() <= 39
942 && !owner.starts_with('-')
943 && !owner.ends_with('-')
944 && !owner.contains("--")
945 && owner
946 .bytes()
947 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
948}
949
950fn valid_environment_name(name: &str) -> bool {
951 let mut bytes = name.bytes();
952 bytes
953 .next()
954 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
955 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
956}
957
958#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
959struct StatusName(String);
960
961impl StatusName {
962 fn new(name: &str) -> Self {
963 Self(name.to_lowercase())
964 }
965}
966
967fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
968 value
969 .get(field)
970 .and_then(Value::as_str)
971 .ok_or_else(|| SourceError::Malformed {
972 message: format!("GitHub response is missing string field {field}"),
973 })
974}
975
976const METADATA_FIELD: &str = "onetaskgraph.metadata";
977
978fn metadata_field(field_values: &Value) -> Result<BTreeMap<String, Value>, SourceError> {
979 let nodes = field_values
980 .get("nodes")
981 .and_then(Value::as_array)
982 .ok_or_else(|| SourceError::Malformed {
983 message: "GitHub project item fieldValues.nodes is not an array".into(),
984 })?;
985 let Some(text) = nodes
986 .iter()
987 .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(METADATA_FIELD))
988 .and_then(|node| node.get("text"))
989 else {
990 return Ok(BTreeMap::new());
991 };
992 text.as_str()
993 .ok_or_else(|| SourceError::Malformed {
994 message: format!("GitHub {METADATA_FIELD} field text is not a string"),
995 })
996 .and_then(|text| {
997 serde_json::from_str(text).map_err(|error| SourceError::Malformed {
998 message: format!(
999 "GitHub {METADATA_FIELD} field is not canonical JSON metadata: {error}"
1000 ),
1001 })
1002 })
1003}
1004
1005fn repositories(content: &Value, field_values: &Value) -> Result<Vec<Repository>, SourceError> {
1006 if let Some(origin) = content
1007 .pointer("/repository/nameWithOwner")
1008 .and_then(Value::as_str)
1009 {
1010 return Repository::try_from(format!("github.com/{origin}"))
1011 .map(|repository| vec![repository])
1012 .map_err(|message| SourceError::Malformed { message });
1013 }
1014 repositories_from_metadata(&metadata_field(field_values)?)
1015}
1016
1017const PROJECT_METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
1018const PROJECT_METADATA_CLOSE: &str = "\n-->";
1019
1020fn metadata_description(
1021 description: Option<String>,
1022) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
1023 let Some(description) = description else {
1024 return Ok((None, BTreeMap::new()));
1025 };
1026 let Some(start) = description.rfind(PROJECT_METADATA_OPEN) else {
1027 return Ok((Some(description), BTreeMap::new()));
1028 };
1029 let value_start = start + PROJECT_METADATA_OPEN.len();
1030 let Some(relative_end) = description[value_start..].find(PROJECT_METADATA_CLOSE) else {
1031 return Err(SourceError::Malformed {
1032 message: "unterminated onetaskgraph metadata slot in GitHub project description".into(),
1033 });
1034 };
1035 let value_end = value_start + relative_end;
1036 if !description[value_end + PROJECT_METADATA_CLOSE.len()..]
1037 .trim()
1038 .is_empty()
1039 {
1040 return Ok((Some(description), BTreeMap::new()));
1041 }
1042 let metadata = serde_json::from_str(&description[value_start..value_end]).map_err(|error| {
1043 SourceError::Malformed {
1044 message: format!("invalid canonical JSON in GitHub project metadata slot: {error}"),
1045 }
1046 })?;
1047 let visible = description[..start].trim_end();
1048 Ok(((!visible.is_empty()).then(|| visible.into()), metadata))
1049}
1050
1051fn repositories_from_metadata(
1052 metadata: &BTreeMap<String, Value>,
1053) -> Result<Vec<Repository>, SourceError> {
1054 Repository::from_metadata(metadata).map_err(|message| SourceError::Malformed { message })
1055}
1056fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
1057 value
1058 .get(field)
1059 .and_then(Value::as_bool)
1060 .ok_or_else(|| SourceError::Malformed {
1061 message: format!("GitHub response is missing boolean field {field}"),
1062 })
1063}
1064fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
1065 match value.get(field) {
1066 None | Some(Value::Null) => Ok(None),
1067 Some(value) => value
1068 .as_str()
1069 .map(Some)
1070 .ok_or_else(|| SourceError::Malformed {
1071 message: format!("GitHub response field {field} is not a string or null"),
1072 }),
1073 }
1074}
1075fn optional_nodes<'a>(
1076 connection: Option<&'a Value>,
1077 name: &str,
1078) -> Result<Option<&'a Vec<Value>>, SourceError> {
1079 match connection {
1080 None | Some(Value::Null) => Ok(None),
1081 Some(value) => value
1082 .get("nodes")
1083 .and_then(Value::as_array)
1084 .map(Some)
1085 .ok_or_else(|| SourceError::Malformed {
1086 message: format!("GitHub {name}.nodes is not an array"),
1087 }),
1088 }
1089}
1090fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
1091 let page_info = connection
1092 .get("pageInfo")
1093 .ok_or_else(|| SourceError::Malformed {
1094 message: format!("GitHub {name} has no pageInfo"),
1095 })?;
1096 if required_bool(page_info, "hasNextPage")? {
1097 return Err(SourceError::Malformed {
1098 message: format!(
1099 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
1100 ),
1101 });
1102 }
1103 Ok(())
1104}
1105fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
1106 optional_str(value, field)?
1107 .map(|timestamp| {
1108 timestamp.parse().map_err(|error| SourceError::Malformed {
1109 message: format!("GitHub response field {field} is not a timestamp: {error}"),
1110 })
1111 })
1112 .transpose()
1113}
1114fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
1115 if page.limit == 0 {
1116 Err(SourceError::Config {
1117 message: "page limit must be at least 1".into(),
1118 })
1119 } else {
1120 Ok(())
1121 }
1122}
1123fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
1124 let page = connection
1125 .get("pageInfo")
1126 .filter(|value| value.is_object())
1127 .ok_or_else(|| SourceError::Malformed {
1128 message: "GitHub connection is missing pageInfo".into(),
1129 })?;
1130 if required_bool(page, "hasNextPage")? {
1131 let cursor = required_str(page, "endCursor")?;
1132 validate_cursor_progress(None, cursor)?;
1133 Ok(Some(Cursor(cursor.into())))
1134 } else {
1135 Ok(None)
1136 }
1137}
1138fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
1139 if next.is_empty() || previous == Some(next) {
1140 Err(SourceError::Malformed {
1141 message: "GitHub pagination cursor is empty or did not advance".into(),
1142 })
1143 } else {
1144 Ok(())
1145 }
1146}
1147fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
1148 cursor.map_or(Ok(0), |c| {
1149 c.0.parse().map_err(|_| SourceError::Config {
1150 message: "label cursor is invalid".into(),
1151 })
1152 })
1153}
1154fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
1155 if offset > items.len() {
1156 return Page::last(vec![]);
1157 }
1158 let tail = items.split_off(offset);
1159 let mut selected = tail;
1160 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
1161 selected.truncate(limit);
1162 Page {
1163 items: selected,
1164 next,
1165 }
1166}