Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 project.
2//!
3//! A project maps to the configured GitHub `ProjectV2`, not a repository: one Projects v2
4//! board can contain work from several repositories and draft work from none. Project fields
5//! read `ProjectV2.id`, `title`, `shortDescription`, `url`, `createdAt`, and `updatedAt`. Tasks
6//! map from `ProjectV2Item.content` (`Issue`, `PullRequest`, or `DraftIssue`); labels read the
7//! content's `labels` connection and `ProjectV2ItemFieldLabelValue`.
8//!
9//! Status reads the item value whose `ProjectV2ItemFieldSingleSelectValue.field.name` is
10//! `Status`. Its option name is retained. The default maps Backlog, Todo/Open, In Progress/In
11//! Review, Done/Closed/Merged, and Cancelled/Canceled; `status_mapping` overrides option names
12//! case-insensitively, and all other user-defined names remain `Unknown`.
13//!
14//! `ProjectV2.items` pages but has no label, status, orphan, or content-search arguments.
15//! Project listing alone is native; the plugin ignores every unsupported query predicate so the
16//! engine can compensate from the wider result. Dependencies traverse underlying `Issue` nodes.
17//! `Issue.blockedBy` supplies `DependsOn` edges and `Issue.blocking` supplies `DependedOnBy`
18//! edges; pull requests and draft issues have neither field and therefore return an empty edge
19//! page. Both dependency capabilities are `BothDirections`; project dependency reads aggregate
20//! the configured project's issue edges. Projects v2 has no native project-to-project relationship,
21//! so those aggregate edges use the related issues' `projectItems.project.id`.
22//!
23//! Live verification is non-destructive by construction: [`TaskSource`] has read operations only
24//! and this crate sends GraphQL `query` operations only, with no mutation for setup or teardown.
25#![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
41/// The registry name for this plugin.
42pub const KIND: &str = "github-projects";
43/// GitHub's maximum connection page size.
44pub const MAX_PAGE_SIZE: u32 = 100;
45/// Nested connection size which keeps GitHub's worst-case query below its node limit.
46const NESTED_PAGE_SIZE: u32 = 50;
47
48/// Exact GraphQL query documents issued by this plugin.
49///
50/// Keeping the production documents here lets the pinned-schema test validate the same bytes
51/// that are sent to GitHub, rather than a test-only copy which could drift independently.
52pub mod graphql {
53    /// Reads the configured project and its task page.
54    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    /// Reads both dependency directions for one issue.
71    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    /// Continues the projects connection for an issue related to a dependency.
73    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    /// Reads issue dependencies and the projects containing each related issue.
75    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/// Configuration for one GitHub Projects v2 project.
86#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
87#[serde(default, deny_unknown_fields)]
88pub struct GitHubProjectsConfig {
89    /// Login of the user or organization which owns the project.
90    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
91    /// The project number shown in its GitHub URL.
92    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
93    /// Environment variable containing a GitHub token with project read access.
94    #[serde(default = "default_token_env")]
95    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
96    /// GraphQL endpoint. GitHub Enterprise installations may override it.
97    #[serde(default = "default_endpoint")]
98    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
99    /// Case-insensitive project status name to normalized category mapping.
100    #[serde(default)]
101    pub status_mapping: BTreeMap<String, StatusCategory>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; normalization validates keys and converts them to `StatusName`.
102}
103
104/// Factory for [`GitHubProjectsSource`].
105#[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
138/// A source which reads GitHub afresh for every operation.
139pub struct GitHubProjectsSource {
140    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
141    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
142    endpoint: Url,
143    token: SecretString,
144    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
145    statuses: BTreeMap<StatusName, StatusCategory>,
146    client: Client,
147}
148
149impl GitHubProjectsSource {
150    /// Validate configuration and capture the named credential without exposing it.
151    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    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
285    // GraphQL cannot independently page them inside the outer item page. This source page is
286    // deliberately bounded at that published maximum; the live drift journey exercises it.
287    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                    // Pull requests and draft issues are valid project tasks, but the inline
733                    // `... on Issue` selection intentionally yields no dependency connection.
734                    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}