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