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