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, 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
42/// The registry name for this plugin.
43pub const KIND: &str = "github-projects";
44/// GitHub's maximum connection page size.
45pub const MAX_PAGE_SIZE: u32 = 100;
46/// Nested connection size which keeps GitHub's worst-case query below its node limit.
47const NESTED_PAGE_SIZE: u32 = 50;
48
49/// Exact GraphQL query documents issued by this plugin.
50///
51/// Keeping the production documents here lets the pinned-schema test validate the same bytes
52/// that are sent to GitHub, rather than a test-only copy which could drift independently.
53pub mod graphql {
54    /// Reads the configured project and its task page.
55    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    /// Reads both dependency directions for one issue.
73    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    /// Continues the projects connection for an issue related to a dependency.
75    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    /// Reads issue dependencies and the projects containing each related issue.
77    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/// Configuration for one GitHub Projects v2 project.
88#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
89#[serde(default, deny_unknown_fields)]
90pub struct GitHubProjectsConfig {
91    /// Login of the user or organization which owns the project.
92    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
93    /// The project number shown in its GitHub URL.
94    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
95    /// Environment variable containing a GitHub token with project read access.
96    #[serde(default = "default_token_env")]
97    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
98    /// GraphQL endpoint. GitHub Enterprise installations may override it.
99    #[serde(default = "default_endpoint")]
100    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
101    /// Case-insensitive project status name to normalized category mapping.
102    #[serde(default)]
103    pub status_mapping: BTreeMap<String, StatusCategory>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; normalization validates keys and converts them to `StatusName`.
104}
105
106/// Factory for [`GitHubProjectsSource`].
107#[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
141/// A source which reads GitHub afresh for every operation.
142pub struct GitHubProjectsSource {
143    /// This source's configured name, so a recorded far end naming it can be told from
144    /// one naming a system this source knows nothing about.
145    name: SourceName,
146    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
147    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
148    endpoint: Url,
149    token: SecretString,
150    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
151    statuses: BTreeMap<StatusName, StatusCategory>,
152    client: Client,
153}
154
155impl GitHubProjectsSource {
156    /// Validate configuration and capture the named credential without exposing it.
157    ///
158    /// `name` is this source's configured name, kept for one comparison: a far end
159    /// recorded as `<name>:<native>` is an item of this same source, which its own
160    /// relationship was supposed to hold.
161    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    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
297    // GraphQL cannot independently page them inside the outer item page. This source page is
298    // deliberately bounded at that published maximum; the live drift journey exercises it.
299    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        // Asked for even in the recorded phase, whose page reads nothing from the
500        // connection: `__typename` is what says whether this item has a native
501        // relationship at all, and that is what decides which far ends the reserved key is
502        // allowed to hold.
503        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        // A draft or a pull request has neither `blockedBy` nor `blocking`, so nothing it
524        // depends on can be named natively and the reserved key may hold any far end. An
525        // issue's connections hold issues, so the key may not hold one of those.
526        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        // `from` depends on `to`, always. GitHub spells the same relationship from either
556        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
557        // it — so the near item is `from` in one direction and `to` in the other.
558        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    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
589    /// a far end in another source has to live: no GitHub issue relationship can name one.
590    ///
591    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
592    /// source never writes one down.
593    ///
594    /// The metadata lives on the *project item*, not on the issue this method is given, so
595    /// reading it costs one board scan. That is why it happens once the native connection
596    /// is spent rather than on every page.
597    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                    // Pull requests and draft issues are valid project tasks, but the inline
819                    // `... on Issue` selection intentionally yields no dependency connection.
820                    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                            // Same orientation as the task level above, one level up.
832                            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            // A board's edges are aggregated from its issues, so another board is exactly
859            // what this source can relate it to — and exactly what the reserved key must
860            // not hold.
861            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
877/// Where the recorded tail of a task-dependency walk resumes; see
878/// [`GitHubProjectsSource::recorded_task_edges`].
879const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
880
881/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
882///
883/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
884/// is derived from the far end, never written down on the near item — so only a forward
885/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
886/// it did not come from, and it is told so rather than answered with an empty page that
887/// reads as a walk which ended.
888fn 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}