1#![deny(missing_docs)]
26
27use std::collections::BTreeMap;
28
29use chrono::{DateTime, Utc};
30use onetaskgraph_plugin_api::{
31 Capabilities, Cursor, DependencyEdge, DependencyKind, DependencySupport, Direction, Health,
32 Label, NativeId, Page, PageRequest, Project, ProjectQuery, SecretResolver, SourceError,
33 SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery, TaskSource,
34};
35use reqwest::{Client, StatusCode, Url};
36use schemars::{Schema, schema_for};
37use secrecy::{ExposeSecret, SecretString};
38use serde::Deserialize;
39use serde_json::{Value, json};
40
41pub const KIND: &str = "github-projects";
43pub const MAX_PAGE_SIZE: u32 = 100;
45const NESTED_PAGE_SIZE: u32 = 50;
47
48pub mod graphql {
53 pub const PROJECT: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!){
55 owner:repositoryOwner(login:$owner){
56 ... on ProjectV2Owner{projectV2(number:$number){...Project}}
57 }
58 } fragment Project on ProjectV2 { id title shortDescription url createdAt updatedAt closed
59 items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
60 ... on ProjectV2ItemFieldSingleSelectValue{name field{
61 ... on ProjectV2SingleSelectField{name}
62 }}
63 ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
64 }pageInfo{hasNextPage}} content{
65 ... on Issue{id title body url createdAt updatedAt state labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
66 ... on PullRequest{id title body url createdAt updatedAt state labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
67 ... on DraftIssue{id title body createdAt updatedAt}
68 }} pageInfo{hasNextPage endCursor}}
69 }"#;
70 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}}}}}"#;
72 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}}}}}"#;
74 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}}}}}"#;
76}
77
78fn default_token_env() -> String {
79 "GH_PROJECTS_TOKEN".to_owned()
80}
81fn default_endpoint() -> String {
82 "https://api.github.com/graphql".to_owned()
83}
84
85#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
87#[serde(default, deny_unknown_fields)]
88pub struct GitHubProjectsConfig {
89 pub owner: String, pub project_number: u32, #[serde(default = "default_token_env")]
95 pub token_env: String, #[serde(default = "default_endpoint")]
98 pub endpoint: String, #[serde(default)]
101 pub status_mapping: BTreeMap<String, StatusCategory>, }
103
104#[derive(Debug, Clone, Copy, Default)]
106pub struct Plugin;
107
108impl SourcePlugin for Plugin {
109 fn kind(&self) -> &'static str {
110 KIND
111 }
112 fn config_schema(&self) -> Schema {
113 schema_for!(GitHubProjectsConfig)
114 }
115 fn build(
116 &self,
117 name: &SourceName,
118 config: &Value,
119 secrets: &dyn SecretResolver,
120 ) -> Result<Box<dyn TaskSource>, SourceError> {
121 let config: GitHubProjectsConfig =
122 serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
123 message: format!("source {name}: {e}"),
124 })?;
125 let source = GitHubProjectsSource::new(config, secrets).map_err(|error| match error {
126 SourceError::Config { message } => SourceError::Config {
127 message: format!("source {name}: {message}"),
128 },
129 SourceError::Auth { message } => SourceError::Auth {
130 message: format!("source {name}: {message}"),
131 },
132 other => other,
133 })?;
134 Ok(Box::new(source))
135 }
136}
137
138pub struct GitHubProjectsSource {
140 owner: String, project_number: u32, endpoint: Url,
143 token: SecretString,
144 credential_name: String, statuses: BTreeMap<StatusName, StatusCategory>,
146 client: Client,
147}
148
149impl GitHubProjectsSource {
150 pub fn new(
152 config: GitHubProjectsConfig,
153 secrets: &dyn SecretResolver,
154 ) -> Result<Self, SourceError> {
155 if !valid_github_owner(&config.owner) {
156 return Err(SourceError::Config {
157 message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
158 });
159 }
160 if config.project_number == 0 || config.project_number > i32::MAX as u32 {
161 return Err(SourceError::Config {
162 message: format!("project_number must be between 1 and {}", i32::MAX),
163 });
164 }
165 if !valid_environment_name(&config.token_env) {
166 return Err(SourceError::Config {
167 message: "token_env must be a valid environment-variable name".into(),
168 });
169 }
170 let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
171 message: format!("endpoint is not a valid URL: {e}"),
172 })?;
173 if endpoint.scheme() != "https"
174 && !(endpoint.scheme() == "http"
175 && endpoint
176 .host_str()
177 .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
178 {
179 return Err(SourceError::Config {
180 message:
181 "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
182 .into(),
183 });
184 }
185 let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
186 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),
187 })?;
188 Ok(Self {
189 owner: config.owner,
190 project_number: config.project_number,
191 endpoint,
192 token,
193 credential_name: config.token_env,
194 statuses: normalize_status_mapping(config.status_mapping)?,
195 client: Client::builder()
196 .user_agent("onetaskgraph")
197 .build()
198 .map_err(|e| SourceError::Config {
199 message: format!("cannot build HTTP client: {e}"),
200 })?,
201 })
202 }
203
204 async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
205 let response = self
206 .client
207 .post(self.endpoint.clone())
208 .bearer_auth(self.token.expose_secret())
209 .json(&json!({"query": query, "variables": variables}))
210 .send()
211 .await
212 .map_err(|e| SourceError::Unavailable {
213 message: format!("GitHub GraphQL request failed: {e}"),
214 })?;
215 let status = response.status();
216 let retry_after = response
217 .headers()
218 .get("retry-after")
219 .and_then(|v| v.to_str().ok())
220 .and_then(|v| v.parse().ok());
221 let exhausted = response
222 .headers()
223 .get("x-ratelimit-remaining")
224 .and_then(|v| v.to_str().ok())
225 == Some("0");
226 if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
227 return Err(SourceError::RateLimited {
228 retry_after_seconds: retry_after,
229 });
230 }
231 if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
232 return Err(SourceError::Auth {
233 message: format!(
234 "GitHub rejected the configured credential with HTTP {status}; grant it read:project and repository Issues read access"
235 ),
236 });
237 }
238 if !status.is_success() {
239 return Err(SourceError::Unavailable {
240 message: format!("GitHub GraphQL returned HTTP {status}"),
241 });
242 }
243 let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
244 message: format!("GitHub returned invalid JSON: {e}"),
245 })?;
246 let errors = body
247 .get("errors")
248 .map(|value| {
249 value.as_array().ok_or_else(|| SourceError::Malformed {
250 message: "GitHub response errors is not an array".into(),
251 })
252 })
253 .transpose()?;
254 if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
255 let messages = errors
256 .iter()
257 .filter_map(|e| e.get("message").and_then(Value::as_str))
258 .collect::<Vec<_>>()
259 .join("; ");
260 let message = if messages.is_empty() {
261 "GitHub returned GraphQL errors".into()
262 } else {
263 messages
264 };
265 let normalized = message.to_ascii_lowercase();
266 if normalized.contains("resource not accessible") || normalized.contains("scope") {
267 return Err(SourceError::Auth {
268 message: format!(
269 "{message}; grant {} read:project and repository Issues read access",
270 self.credential_name
271 ),
272 });
273 }
274 return Err(SourceError::Refused { message });
275 }
276 body.get("data")
277 .filter(|data| data.is_object())
278 .cloned()
279 .ok_or_else(|| SourceError::Malformed {
280 message: "GitHub response has no data object".into(),
281 })
282 }
283
284 async fn project_value(
288 &self,
289 items_after: Option<&str>,
290 items_first: u32,
291 ) -> Result<Value, SourceError> {
292 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?;
293 data.pointer("/owner/projectV2")
294 .filter(|v| !v.is_null())
295 .cloned()
296 .ok_or_else(|| SourceError::Refused {
297 message: format!(
298 "GitHub project {}/{} was not found or is not visible to the token",
299 self.owner, self.project_number
300 ),
301 })
302 }
303
304 fn status(&self, item: &Value) -> Result<Status, SourceError> {
305 let fields = item
306 .pointer("/fieldValues/nodes")
307 .and_then(Value::as_array)
308 .expect("task validates fieldValues.nodes before mapping status");
309 let name = fields
310 .iter()
311 .find(|v| v.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
312 .map(|value| required_str(value, "name"))
313 .transpose()?
314 .or(optional_str(
315 item.get("content").unwrap_or(&Value::Null),
316 "state",
317 )?)
318 .unwrap_or("Unknown")
319 .to_owned();
320 let category = self
321 .statuses
322 .get(&StatusName::new(&name))
323 .copied()
324 .unwrap_or_else(|| match name.to_ascii_lowercase().as_str() {
325 "backlog" => StatusCategory::Backlog,
326 "todo" | "open" => StatusCategory::Todo,
327 "in progress" | "in review" => StatusCategory::InProgress,
328 "done" | "closed" | "merged" => StatusCategory::Done,
329 "cancelled" | "canceled" => StatusCategory::Cancelled,
330 _ => StatusCategory::Unknown,
331 });
332 Ok(Status { category, name })
333 }
334
335 fn labels(item: &Value) -> Result<Vec<Label>, SourceError> {
336 let direct = optional_nodes(item.pointer("/content/labels"), "content labels")?;
337 let field_values = item
338 .pointer("/fieldValues/nodes")
339 .and_then(Value::as_array)
340 .expect("task validates fieldValues.nodes before mapping labels");
341 let field = field_values
342 .iter()
343 .find_map(|value| value.get("labels"))
344 .map(|labels| optional_nodes(Some(labels), "field labels"))
345 .transpose()?
346 .flatten();
347 let labels = direct
348 .into_iter()
349 .flatten()
350 .chain(field.into_iter().flatten())
351 .map(|v| {
352 Ok(Label {
353 id: NativeId(required_str(v, "id")?.to_owned()),
354 name: required_str(v, "name")?.to_owned(),
355 color: optional_str(v, "color")?.map(str::to_owned),
356 })
357 })
358 .collect::<Result<Vec<_>, SourceError>>()?
359 .into_iter()
360 .fold(Vec::new(), |mut labels, label| {
361 if !labels.iter().any(|x: &Label| x.id == label.id) {
362 labels.push(label);
363 }
364 labels
365 });
366 Ok(labels)
367 }
368
369 fn task(&self, project_id: &str, item: &Value) -> Result<Option<Task>, SourceError> {
370 let content = item.get("content").ok_or_else(|| SourceError::Malformed {
371 message: "GitHub project item is missing content".into(),
372 })?;
373 if content.is_null() {
374 return Ok(None);
375 }
376 let field_values = item
377 .get("fieldValues")
378 .ok_or_else(|| SourceError::Malformed {
379 message: "GitHub project item is missing fieldValues".into(),
380 })?;
381 complete_connection(field_values, "project item field values")?;
382 field_values
383 .get("nodes")
384 .and_then(Value::as_array)
385 .ok_or_else(|| SourceError::Malformed {
386 message: "GitHub project item fieldValues.nodes is not an array".into(),
387 })?;
388 if let Some(labels) = content.get("labels") {
389 complete_connection(labels, "content labels")?;
390 }
391 for field_value in field_values["nodes"].as_array().expect("validated above") {
392 if let Some(labels) = field_value.get("labels") {
393 complete_connection(labels, "project item field labels")?;
394 }
395 }
396 Ok(Some(Task {
397 id: NativeId(required_str(content, "id")?.to_owned()),
398 title: required_str(content, "title")?.to_owned(),
399 content: optional_str(content, "body")?
400 .filter(|s| !s.is_empty())
401 .map(str::to_owned),
402 status: self.status(item)?,
403 labels: Self::labels(item)?,
404 project: Some(NativeId(project_id.to_owned())),
405 url: optional_str(content, "url")?.map(str::to_owned),
406 created_at: optional_time(content, "createdAt")?,
407 updated_at: optional_time(content, "updatedAt")?,
408 }))
409 }
410
411 fn project(&self, value: &Value) -> Result<Project, SourceError> {
412 let id = required_str(value, "id")?;
413 Ok(Project {
414 id: NativeId(id.into()),
415 title: required_str(value, "title")?.into(),
416 content: optional_str(value, "shortDescription")?
417 .filter(|s| !s.is_empty())
418 .map(str::to_owned),
419 status: Status {
420 category: if required_bool(value, "closed")? {
421 StatusCategory::Done
422 } else {
423 StatusCategory::InProgress
424 },
425 name: if required_bool(value, "closed")? {
426 "Closed"
427 } else {
428 "Open"
429 }
430 .into(),
431 },
432 labels: vec![],
433 url: optional_str(value, "url")?.map(str::to_owned),
434 created_at: optional_time(value, "createdAt")?,
435 updated_at: optional_time(value, "updatedAt")?,
436 })
437 }
438
439 async fn all_tasks(&self) -> Result<Vec<Task>, SourceError> {
440 let mut after = None;
441 let mut tasks = Vec::new();
442 loop {
443 let project = self.project_value(after.as_deref(), MAX_PAGE_SIZE).await?;
444 let project_id = required_str(&project, "id")?;
445 let items = project
446 .pointer("/items/nodes")
447 .and_then(Value::as_array)
448 .ok_or_else(|| SourceError::Malformed {
449 message: "GitHub project items.nodes is not an array".into(),
450 })?;
451 for item in items {
452 if let Some(task) = self.task(project_id, item)? {
453 tasks.push(task);
454 }
455 }
456 let page =
457 project
458 .pointer("/items/pageInfo")
459 .ok_or_else(|| SourceError::Malformed {
460 message: "GitHub project items have no pageInfo".into(),
461 })?;
462 if !required_bool(page, "hasNextPage")? {
463 break;
464 }
465 let next = required_str(page, "endCursor")?;
466 validate_cursor_progress(after.as_deref(), next)?;
467 after = Some(next.to_owned());
468 }
469 Ok(tasks)
470 }
471
472 async fn dependencies(
473 &self,
474 id: &NativeId,
475 direction: Direction,
476 page: &PageRequest,
477 ) -> Result<Page<DependencyEdge>, SourceError> {
478 validate_page(page)?;
479 let data = self.graphql(graphql::TASK_DEPENDENCIES, json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),"after":page.cursor.as_ref().map(|c| c.0.as_str())})).await?;
480 let node =
481 data.get("node")
482 .filter(|v| !v.is_null())
483 .ok_or_else(|| SourceError::Refused {
484 message: format!(
485 "GitHub item {} was not found or does not support dependencies",
486 id.0
487 ),
488 })?;
489 let connection_name = match direction {
490 Direction::DependsOn => "blockedBy",
491 Direction::DependedOnBy => "blocking",
492 };
493 if required_str(node, "__typename")? != "Issue" {
494 return Ok(Page {
495 items: Vec::new(),
496 next: None,
497 });
498 }
499 let connection = node
500 .get(connection_name)
501 .ok_or_else(|| SourceError::Malformed {
502 message: "GitHub dependency response is missing its connection".into(),
503 })?;
504 let nodes = connection
505 .get("nodes")
506 .and_then(Value::as_array)
507 .ok_or_else(|| SourceError::Malformed {
508 message: "GitHub dependency response nodes is not an array".into(),
509 })?;
510 let items = nodes
511 .iter()
512 .map(|value| {
513 let related = NativeId(required_str(value, "id")?.into());
514 Ok(match direction {
515 Direction::DependsOn => DependencyEdge {
516 from: related,
517 to: id.clone(),
518 kind: DependencyKind::Blocks,
519 },
520 Direction::DependedOnBy => DependencyEdge {
521 from: id.clone(),
522 to: related,
523 kind: DependencyKind::Blocks,
524 },
525 })
526 })
527 .collect::<Result<Vec<_>, SourceError>>()?;
528 let next = next_cursor(connection)?;
529 if let Some(next) = &next {
530 validate_cursor_progress(
531 page.cursor.as_ref().map(|cursor| cursor.0.as_str()),
532 &next.0,
533 )?;
534 }
535 Ok(Page { items, next })
536 }
537
538 async fn related_issue_projects(&self, issue: &Value) -> Result<Vec<NativeId>, SourceError> {
539 let issue_id = required_str(issue, "id")?;
540 let mut connection =
541 issue
542 .get("projectItems")
543 .cloned()
544 .ok_or_else(|| SourceError::Malformed {
545 message: "GitHub related issue is missing projectItems".into(),
546 })?;
547 let mut projects = Vec::new();
548 let mut previous = None;
549 loop {
550 let nodes = connection
551 .get("nodes")
552 .and_then(Value::as_array)
553 .ok_or_else(|| SourceError::Malformed {
554 message: "GitHub related issue projectItems.nodes is not an array".into(),
555 })?;
556 for item in nodes {
557 projects.push(NativeId(
558 required_str(
559 item.get("project").ok_or_else(|| SourceError::Malformed {
560 message: "GitHub dependency project item has no project".into(),
561 })?,
562 "id",
563 )?
564 .into(),
565 ));
566 }
567 let Some(cursor) = next_cursor(&connection)? else {
568 break;
569 };
570 validate_cursor_progress(previous.as_deref(), &cursor.0)?;
571 previous = Some(cursor.0.clone());
572 let data = self
573 .graphql(
574 graphql::RELATED_PROJECTS,
575 json!({"id":issue_id,"first":MAX_PAGE_SIZE,"after":cursor.0}),
576 )
577 .await?;
578 connection = data.pointer("/node/projectItems").cloned().ok_or_else(|| {
579 SourceError::Malformed {
580 message: "GitHub related issue response is missing projectItems".into(),
581 }
582 })?;
583 }
584 Ok(projects)
585 }
586}
587
588#[async_trait::async_trait]
589impl TaskSource for GitHubProjectsSource {
590 fn kind(&self) -> &'static str {
591 KIND
592 }
593 fn capabilities(&self) -> Capabilities {
594 Capabilities {
595 projects: Support::Native,
596 orphan_tasks: Support::Unsupported,
597 filter_by_label: Support::Unsupported,
598 filter_by_status: Support::Unsupported,
599 search_title: Support::Unsupported,
600 search_content: Support::Unsupported,
601 task_dependencies: DependencySupport::BothDirections,
602 project_dependencies: DependencySupport::BothDirections,
603 max_page_size: MAX_PAGE_SIZE,
604 }
605 }
606 async fn health(&self) -> Result<Health, SourceError> {
607 let project = self.project_value(None, 1).await?;
608 Ok(Health {
609 reachable: true,
610 detail: Some(format!(
611 "reading GitHub project {}/{} ({})",
612 self.owner,
613 self.project_number,
614 required_str(&project, "title")?
615 )),
616 })
617 }
618 async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
619 Ok(self
620 .all_tasks()
621 .await?
622 .into_iter()
623 .find(|task| task.id == *id))
624 }
625 async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
626 let value = self.project_value(None, 1).await?;
627 let project = self.project(&value)?;
628 Ok((project.id == *id).then_some(project))
629 }
630 async fn query_tasks(
631 &self,
632 _query: &TaskQuery,
633 page: &PageRequest,
634 ) -> Result<Page<Task>, SourceError> {
635 validate_page(page)?;
636 let value = self
637 .project_value(page.cursor.as_ref().map(|c| c.0.as_str()), page.limit)
638 .await?;
639 let id = required_str(&value, "id")?;
640 let items_connection = value.get("items").ok_or_else(|| SourceError::Malformed {
641 message: "GitHub project response is missing items".into(),
642 })?;
643 let nodes = items_connection
644 .get("nodes")
645 .and_then(Value::as_array)
646 .ok_or_else(|| SourceError::Malformed {
647 message: "GitHub project items.nodes is not an array".into(),
648 })?;
649 let items = nodes
650 .iter()
651 .map(|item| self.task(id, item))
652 .collect::<Result<Vec<_>, SourceError>>()?
653 .into_iter()
654 .flatten()
655 .collect();
656 let next = next_cursor(items_connection)?;
657 if let Some(next) = &next {
658 validate_cursor_progress(
659 page.cursor.as_ref().map(|cursor| cursor.0.as_str()),
660 &next.0,
661 )?;
662 }
663 Ok(Page { items, next })
664 }
665 async fn query_projects(
666 &self,
667 _query: &ProjectQuery,
668 page: &PageRequest,
669 ) -> Result<Page<Project>, SourceError> {
670 validate_page(page)?;
671 if page.cursor.is_some() {
672 return Err(SourceError::Config {
673 message: "GitHub project listing does not issue page cursors".into(),
674 });
675 }
676 Ok(Page::last(vec![
677 self.project(&self.project_value(None, 1).await?)?,
678 ]))
679 }
680 async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
681 validate_page(page)?;
682 let offset = numeric_cursor(page.cursor.as_ref())?;
683 let mut labels = self
684 .all_tasks()
685 .await?
686 .into_iter()
687 .flat_map(|t| t.labels)
688 .fold(Vec::new(), |mut all, label| {
689 if !all.iter().any(|x: &Label| x.id == label.id) {
690 all.push(label);
691 }
692 all
693 });
694 labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
695 Ok(offset_page(
696 labels,
697 offset,
698 page.limit.min(MAX_PAGE_SIZE) as usize,
699 ))
700 }
701 async fn task_dependencies(
702 &self,
703 id: &NativeId,
704 direction: Direction,
705 page: &PageRequest,
706 ) -> Result<Page<DependencyEdge>, SourceError> {
707 self.dependencies(id, direction, page).await
708 }
709 async fn project_dependencies(
710 &self,
711 id: &NativeId,
712 direction: Direction,
713 page: &PageRequest,
714 ) -> Result<Page<DependencyEdge>, SourceError> {
715 validate_page(page)?;
716 let project = self.project_value(None, 1).await?;
717 if required_str(&project, "id")? != id.0 {
718 return Err(SourceError::Refused {
719 message: format!("GitHub project {} was not found", id.0),
720 });
721 }
722 let mut edges = Vec::new();
723 for task in self.all_tasks().await? {
724 let mut cursor = None;
725 loop {
726 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?;
727 let connection_name = match direction {
728 Direction::DependsOn => "blockedBy",
729 Direction::DependedOnBy => "blocking",
730 };
731 let Some(connection) = data.pointer(&format!("/node/{connection_name}")) else {
732 break;
735 };
736 let related_issues = connection
737 .get("nodes")
738 .and_then(Value::as_array)
739 .ok_or_else(|| SourceError::Malformed {
740 message: "GitHub project dependency nodes is not an array".into(),
741 })?;
742 for related_issue in related_issues {
743 for related in self.related_issue_projects(related_issue).await? {
744 if related != *id {
745 edges.push(match direction {
746 Direction::DependsOn => DependencyEdge {
747 from: related,
748 to: id.clone(),
749 kind: DependencyKind::Blocks,
750 },
751 Direction::DependedOnBy => DependencyEdge {
752 from: id.clone(),
753 to: related,
754 kind: DependencyKind::Blocks,
755 },
756 });
757 }
758 }
759 }
760 let next = next_cursor(connection)?;
761 if let Some(next) = &next {
762 validate_cursor_progress(
763 cursor.as_ref().map(|value: &Cursor| value.0.as_str()),
764 &next.0,
765 )?;
766 }
767 cursor = next;
768 if cursor.is_none() {
769 break;
770 }
771 }
772 }
773 let offset = numeric_cursor(page.cursor.as_ref())?;
774 Ok(offset_page(edges, offset, page.limit as usize))
775 }
776}
777
778fn normalize_status_mapping(
779 mapping: BTreeMap<String, StatusCategory>,
780) -> Result<BTreeMap<StatusName, StatusCategory>, SourceError> {
781 let mut normalized = BTreeMap::new();
782 for (name, category) in mapping {
783 if name.trim().is_empty() {
784 return Err(SourceError::Config {
785 message: "status_mapping contains a blank status name".into(),
786 });
787 }
788 let key = StatusName::new(&name);
789 if normalized.insert(key, category).is_some() {
790 return Err(SourceError::Config {
791 message: format!("status_mapping contains case-insensitive duplicate {name}"),
792 });
793 }
794 }
795 Ok(normalized)
796}
797
798fn valid_github_owner(owner: &str) -> bool {
799 !owner.is_empty()
800 && owner.len() <= 39
801 && !owner.starts_with('-')
802 && !owner.ends_with('-')
803 && !owner.contains("--")
804 && owner
805 .bytes()
806 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
807}
808
809fn valid_environment_name(name: &str) -> bool {
810 let mut bytes = name.bytes();
811 bytes
812 .next()
813 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
814 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
818struct StatusName(String);
819
820impl StatusName {
821 fn new(name: &str) -> Self {
822 Self(name.to_lowercase())
823 }
824}
825
826fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
827 value
828 .get(field)
829 .and_then(Value::as_str)
830 .ok_or_else(|| SourceError::Malformed {
831 message: format!("GitHub response is missing string field {field}"),
832 })
833}
834fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
835 value
836 .get(field)
837 .and_then(Value::as_bool)
838 .ok_or_else(|| SourceError::Malformed {
839 message: format!("GitHub response is missing boolean field {field}"),
840 })
841}
842fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
843 match value.get(field) {
844 None | Some(Value::Null) => Ok(None),
845 Some(value) => value
846 .as_str()
847 .map(Some)
848 .ok_or_else(|| SourceError::Malformed {
849 message: format!("GitHub response field {field} is not a string or null"),
850 }),
851 }
852}
853fn optional_nodes<'a>(
854 connection: Option<&'a Value>,
855 name: &str,
856) -> Result<Option<&'a Vec<Value>>, SourceError> {
857 match connection {
858 None | Some(Value::Null) => Ok(None),
859 Some(value) => value
860 .get("nodes")
861 .and_then(Value::as_array)
862 .map(Some)
863 .ok_or_else(|| SourceError::Malformed {
864 message: format!("GitHub {name}.nodes is not an array"),
865 }),
866 }
867}
868fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
869 let page_info = connection
870 .get("pageInfo")
871 .ok_or_else(|| SourceError::Malformed {
872 message: format!("GitHub {name} has no pageInfo"),
873 })?;
874 if required_bool(page_info, "hasNextPage")? {
875 return Err(SourceError::Malformed {
876 message: format!(
877 "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
878 ),
879 });
880 }
881 Ok(())
882}
883fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
884 optional_str(value, field)?
885 .map(|timestamp| {
886 timestamp.parse().map_err(|error| SourceError::Malformed {
887 message: format!("GitHub response field {field} is not a timestamp: {error}"),
888 })
889 })
890 .transpose()
891}
892fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
893 if page.limit == 0 {
894 Err(SourceError::Config {
895 message: "page limit must be at least 1".into(),
896 })
897 } else {
898 Ok(())
899 }
900}
901fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
902 let page = connection
903 .get("pageInfo")
904 .filter(|value| value.is_object())
905 .ok_or_else(|| SourceError::Malformed {
906 message: "GitHub connection is missing pageInfo".into(),
907 })?;
908 if required_bool(page, "hasNextPage")? {
909 let cursor = required_str(page, "endCursor")?;
910 validate_cursor_progress(None, cursor)?;
911 Ok(Some(Cursor(cursor.into())))
912 } else {
913 Ok(None)
914 }
915}
916fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
917 if next.is_empty() || previous == Some(next) {
918 Err(SourceError::Malformed {
919 message: "GitHub pagination cursor is empty or did not advance".into(),
920 })
921 } else {
922 Ok(())
923 }
924}
925fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
926 cursor.map_or(Ok(0), |c| {
927 c.0.parse().map_err(|_| SourceError::Config {
928 message: "label cursor is invalid".into(),
929 })
930 })
931}
932fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
933 if offset > items.len() {
934 return Page::last(vec![]);
935 }
936 let tail = items.split_off(offset);
937 let mut selected = tail;
938 let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
939 selected.truncate(limit);
940 Page {
941 items: selected,
942 next,
943 }
944}