Skip to main content

onetaskgraph_github_projects/
lib.rs

1//! A stateless onetaskgraph source over one GitHub Projects v2 board.
2//!
3//! **A board is a container of projects, not a project.** Its own `title`,
4//! `shortDescription` and `readme` are never read as an item's fields and are never
5//! written: nothing in this source can rename the board a user configured.
6//!
7//! **A project is an issue and its tasks are that issue's sub-issues.** GitHub's schema
8//! decides that: `Issue` exposes `parent`, `subIssues` and `subIssuesSummary`, and
9//! `DraftIssue` exposes none of them. Creating an issue needs a `repositoryId`, and a
10//! board has none, so [`GitHubProjectsConfig::repository`] names the one repository this
11//! source creates its project and task issues in; a write without it is refused naming
12//! the field.
13//!
14//! **Telling a project from a task.** A board issue is a project when *either* it has
15//! sub-issues *or* it carries [`ItemKind::METADATA_KEY`]; otherwise it is a task. A
16//! sub-issue is always a task, whatever it carries. The marker is sufficient and never
17//! necessary: it is what makes an *empty* project — the state a project copy passes
18//! through between creating the project and filing its first task — readable as a
19//! project, while the sub-issue arm lets a person author a project on the board by hand
20//! with no knowledge of this product's metadata at all. Pull requests are neither a
21//! project nor a task and are ignored.
22//!
23//! **Where metadata lives.** Short typed things go to typed fields and native relations:
24//! status to the board's `Status` single-select and the issue's own state, the copy
25//! origin to a source-owned `onetaskgraph.origin` text field, and dependencies to
26//! `blockedBy` and to sub-issue links. Unbounded caller JSON goes in a trailing
27//! `<!-- onetaskgraph.metadata ... -->` comment at the end of the issue body — the same
28//! encoding `docs/metadata.md` settles for Linear, not a second one. A ProjectV2 text
29//! field is length-bounded and `shortDescription` is capped at 300 characters, which is
30//! why neither can hold a caller's own prose.
31//!
32//! **Status.** `status_mapping` is per-instance configuration from a status category to
33//! `null`, a board `Status` option name, or a closed state of `completed` or
34//! `not-planned`. Nothing here ever calls `updateProjectV2Field`: that mutation's
35//! `singleSelectOptions` *overwrites* a field's option set, so no addition is additive
36//! and a mistake destroys every item's status. A status this board cannot represent is a
37//! refusal naming the status and the instance instead.
38//!
39//! `done` closes the issue by default because GitHub derives `subIssuesSummary.completed`
40//! and the board's own `Sub-issues progress` field from closed sub-issues: a plan whose
41//! finished tasks were only moved to a "Done" column would read 0% complete forever.
42//!
43//! Required checks use only the local fixture server; the ignored credentialed lane
44//! verifies the current schema, creates and reads back one uniquely named issue, then
45//! deletes every matching project item and verifies that no residue remains.
46//!
47//! That lane writes only to the board `GH_PROJECTS_OWNER` and `GH_PROJECTS_NUMBER` name,
48//! and only into the repository `GH_PROJECTS_REPOSITORY` names, and skips — as it does
49//! without `GH_PROJECTS_TOKEN` — when any of them is absent. Requiring both to be
50//! nominated is what keeps a credentialed write lane off a board and a repository nobody
51//! nominated; it never asks GitHub which project was updated most recently. Before it
52//! starts, the lane also clears any item titled the way it titles its own artifacts,
53//! which is self-healing after an interrupted run: a process killed between its write and
54//! its cleanup leaves an artifact the next run removes.
55#![deny(missing_docs)]
56
57use std::collections::BTreeMap;
58
59use chrono::{DateTime, Utc};
60use onetaskgraph_plugin_api::{
61    Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
62    Direction, Health, ItemKind, ItemWrite, Label, NativeId, Page, PageRequest, Project,
63    ProjectQuery, Repository, SecretResolver, SourceError, SourceName, SourcePlugin, Status,
64    StatusCategory, Support, Task, TaskQuery, TaskSource, WriteSupport,
65};
66use reqwest::{Client, StatusCode, Url};
67use schemars::{Schema, schema_for};
68use secrecy::{ExposeSecret, SecretString};
69use serde::Deserialize;
70use serde_json::{Value, json};
71
72/// The registry name for this plugin.
73pub const KIND: &str = "github-projects";
74/// GitHub's maximum connection page size.
75pub const MAX_PAGE_SIZE: u32 = 100;
76/// Nested connection size which keeps GitHub's worst-case query below its node limit.
77const NESTED_PAGE_SIZE: u32 = 50;
78
79/// Exact GraphQL query documents issued by this plugin.
80///
81/// Keeping the production documents here lets the pinned-schema test validate the same
82/// bytes that are sent to GitHub, rather than a test-only copy which could drift
83/// independently. No document in this module writes the board itself, and none of them
84/// names `updateProjectV2Field`.
85pub mod graphql {
86    /// Reads the board's fields and one page of its items.
87    pub const BOARD: &str = r#"query($owner:String!,$number:Int!,$first:Int!,$after:String,$nestedFirst:Int!,$duplicates:Boolean!){
88      owner:repositoryOwner(login:$owner){
89        ... on ProjectV2Owner{projectV2(number:$number){...Board}}
90      }
91    } fragment Board on ProjectV2 { id title
92      fields(first:$nestedFirst){nodes{
93        ... on ProjectV2SingleSelectField{__typename id name options{id name}}
94        ... on ProjectV2Field{__typename id name}
95      }pageInfo{hasNextPage}}
96      items(first:$first,after:$after){nodes{id fieldValues(first:$nestedFirst){nodes{
97        ... on ProjectV2ItemFieldSingleSelectValue{name field{
98          ... on ProjectV2SingleSelectField{id name options{id name}}
99        }}
100        ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id name}}}
101        ... on ProjectV2ItemFieldLabelValue{labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
102      }pageInfo{hasNextPage}} content{
103        ... on Issue{__typename id title body url createdAt updatedAt state stateReason(enableDuplicate:$duplicates) repository{nameWithOwner} parent{id} subIssuesSummary{total} labels(first:$nestedFirst){nodes{id name color}pageInfo{hasNextPage}}}
104        ... on PullRequest{__typename id}
105        ... on DraftIssue{__typename id title body createdAt updatedAt}
106      }} pageInfo{hasNextPage endCursor}}
107    }"#;
108    /// Resolves the configured repository's node id, which creating an issue requires.
109    pub const REPOSITORY: &str = r#"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){id nameWithOwner}}"#;
110    /// Reads both dependency directions for one issue, with each far end's own kind.
111    pub const ISSUE_DEPENDENCIES: &str = r#"query($id:ID!,$first:Int!,$after:String){node(id:$id){__typename
112      ... on Issue{
113        blockedBy(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
114        blocking(first:$first,after:$after){nodes{...Related}pageInfo{hasNextPage endCursor}}
115      }}} fragment Related on Issue{id body parent{id} subIssuesSummary{total}}"#;
116    /// Creates one issue in the configured repository.
117    pub const CREATE_ISSUE: &str =
118        r#"mutation($input:CreateIssueInput!){createIssue(input:$input){issue{id}}}"#;
119    /// Puts an existing issue on the configured board.
120    pub const ADD_TO_BOARD: &str = r#"mutation($input:AddProjectV2ItemByIdInput!){addProjectV2ItemById(input:$input){item{id}}}"#;
121    /// Updates an issue's visible fields and its open or closed state in one call.
122    pub const UPDATE_ISSUE: &str =
123        r#"mutation($input:UpdateIssueInput!){updateIssue(input:$input){issue{id}}}"#;
124    /// Updates an existing draft's user-visible fields.
125    pub const UPDATE_DRAFT: &str = r#"mutation($input:UpdateProjectV2DraftIssueInput!){updateProjectV2DraftIssue(input:$input){draftIssue{id}}}"#;
126    /// Updates a text or single-select value on one project item.
127    pub const UPDATE_FIELD: &str = r#"mutation($input:UpdateProjectV2ItemFieldValueInput!){updateProjectV2ItemFieldValue(input:$input){projectV2Item{id}}}"#;
128    /// Files one issue under another as a sub-issue, which is what project membership is.
129    pub const ADD_SUB_ISSUE: &str =
130        r#"mutation($input:AddSubIssueInput!){addSubIssue(input:$input){issue{id} subIssue{id}}}"#;
131    /// Takes one issue back out of its parent.
132    pub const REMOVE_SUB_ISSUE: &str = r#"mutation($input:RemoveSubIssueInput!){removeSubIssue(input:$input){issue{id} subIssue{id}}}"#;
133    /// Adds GitHub's native issue blocked-by relationship.
134    pub const ADD_BLOCKED_BY: &str = r#"mutation($input:AddBlockedByInput!){addBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
135    /// Removes one native issue blocked-by relationship.
136    pub const REMOVE_BLOCKED_BY: &str = r#"mutation($input:RemoveBlockedByInput!){removeBlockedBy(input:$input){issue{id} blockingIssue{id}}}"#;
137}
138
139fn default_token_env() -> String {
140    "GH_PROJECTS_TOKEN".to_owned()
141}
142fn default_endpoint() -> String {
143    "https://api.github.com/graphql".to_owned()
144}
145
146/// Where one status category lands on this board.
147///
148/// `null` — an absent value — disables the category for this instance, and using a
149/// disabled status is a refusal naming the status and the instance.
150#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
151#[serde(untagged)]
152pub enum StatusTargetConfig {
153    /// The name of a `Status` single-select option already on the board.
154    Column(ColumnName),
155    /// A closed issue state, whose reason is what tells done from cancelled.
156    Closed {
157        /// The `IssueClosedStateReason` to close with.
158        closed: ClosedState,
159    },
160}
161
162/// The name of a `Status` single-select option on the board.
163///
164/// Validated on the way in rather than checked later, so a blank option name — which
165/// nothing on a board can be — is a state this type cannot hold.
166#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
167#[serde(try_from = "String")]
168pub struct ColumnName(String);
169
170impl ColumnName {
171    /// The option name, as the board spells it.
172    fn as_str(&self) -> &str {
173        &self.0
174    }
175}
176
177impl TryFrom<String> for ColumnName {
178    type Error = String;
179
180    fn try_from(name: String) -> Result<Self, Self::Error> {
181        if name.trim().is_empty() {
182            return Err("a status_mapping option name cannot be blank".to_owned());
183        }
184        Ok(Self(name))
185    }
186}
187
188/// The two closed states this product can mean.
189///
190/// GitHub's `IssueClosedStateReason` also spells `DUPLICATE`, which is neither finished
191/// work nor abandoned work, so nothing here ever writes it.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "kebab-case")]
194pub enum ClosedState {
195    /// `COMPLETED` — precisely done.
196    Completed,
197    /// `NOT_PLANNED` — precisely cancelled.
198    NotPlanned,
199}
200
201impl ClosedState {
202    const fn reason(self) -> &'static str {
203        match self {
204            Self::Completed => "COMPLETED",
205            Self::NotPlanned => "NOT_PLANNED",
206        }
207    }
208}
209
210/// Configuration for one GitHub Projects v2 board.
211#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
212#[serde(default, deny_unknown_fields)]
213pub struct GitHubProjectsConfig {
214    /// Login of the user or organization which owns the board.
215    pub owner: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates GitHub's owner grammar before private construction.
216    /// The project number shown in the board's GitHub URL.
217    pub project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` bounds this to a positive GraphQL Int.
218    /// `owner/name` of the one repository this source creates its issues in.
219    ///
220    /// A board has no repository of its own and `createIssue` requires one, so a write
221    /// without this is refused naming the field. Reads never need it.
222    pub repository: Option<String>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the `owner/name` grammar before private construction.
223    /// Environment variable containing a fine-grained token with Projects and Issues
224    /// read/write plus Pull requests read-only access for every repository represented on
225    /// the board.
226    #[serde(default = "default_token_env")]
227    pub token_env: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` validates the environment-variable grammar.
228    /// GraphQL endpoint. GitHub Enterprise installations may override it.
229    #[serde(default = "default_endpoint")]
230    pub endpoint: String, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` converts it to the private validated `Url`.
231    /// Per-instance mapping from a status category to where it lands on this board.
232    ///
233    /// A category this does not mention keeps its shipped default: `backlog` to
234    /// "Backlog", `todo` to "Todo", `in-progress` to "In Progress", `done` to closed as
235    /// completed, `cancelled` to closed as not planned, and `draft` and `unknown`
236    /// disabled.
237    #[serde(default)]
238    pub status_mapping: BTreeMap<String, Option<StatusTargetConfig>>, // llmlint: ignore[invalid_states_unrepresentable] Schema DTO; `new` parses each key into a `StatusCategory` and reports an unknown one against this instance.
239}
240
241/// Factory for [`GitHubProjectsSource`].
242#[derive(Debug, Clone, Copy, Default)]
243pub struct Plugin;
244
245impl SourcePlugin for Plugin {
246    fn kind(&self) -> &'static str {
247        KIND
248    }
249    fn config_schema(&self) -> Schema {
250        schema_for!(GitHubProjectsConfig)
251    }
252    fn build(
253        &self,
254        name: &SourceName,
255        config: &Value,
256        secrets: &dyn SecretResolver,
257    ) -> Result<Box<dyn TaskSource>, SourceError> {
258        let config: GitHubProjectsConfig =
259            serde_json::from_value(config.clone()).map_err(|e| SourceError::Config {
260                message: format!("source {name}: {e}"),
261            })?;
262        let source =
263            GitHubProjectsSource::new(name, config, secrets).map_err(|error| match error {
264                SourceError::Config { message } => SourceError::Config {
265                    message: format!("source {name}: {message}"),
266                },
267                SourceError::Auth { message } => SourceError::Auth {
268                    message: format!("source {name}: {message}"),
269                },
270                other => other,
271            })?;
272        Ok(Box::new(source))
273    }
274}
275
276/// Where a status category lands on this board, once configuration is resolved.
277#[derive(Debug, Clone, PartialEq, Eq)]
278enum StatusTarget {
279    /// Not usable against this instance.
280    Disabled,
281    /// The board's `Status` option of this name.
282    Column(ColumnName),
283    /// A closed issue, with the reason that says which closed it means.
284    Closed(ClosedState),
285}
286
287/// Every status category, in the order the vocabulary declares them.
288///
289/// This list mirrors `StatusCategory`, so it carries its own drift gate rather than a
290/// reviewer's attention: [`category_position`] is a wildcard-free match, so a variant
291/// added to the shared vocabulary fails to compile until it is named there, and this
292/// crate's suite asserts that every position that function can return is filled by the
293/// category returning it — which a list still missing the new variant cannot satisfy.
294pub const CATEGORIES: [StatusCategory; 7] = [
295    StatusCategory::Draft,
296    StatusCategory::Backlog,
297    StatusCategory::Todo,
298    StatusCategory::InProgress,
299    StatusCategory::Done,
300    StatusCategory::Cancelled,
301    StatusCategory::Unknown,
302];
303
304/// Where one category sits in [`CATEGORIES`]; see that list for what this pins.
305#[must_use]
306pub const fn category_position(category: StatusCategory) -> usize {
307    match category {
308        StatusCategory::Draft => 0,
309        StatusCategory::Backlog => 1,
310        StatusCategory::Todo => 2,
311        StatusCategory::InProgress => 3,
312        StatusCategory::Done => 4,
313        StatusCategory::Cancelled => 5,
314        StatusCategory::Unknown => 6,
315    }
316}
317
318/// The spelling a status category is configured and reported under.
319fn category_name(category: StatusCategory) -> &'static str {
320    match category {
321        StatusCategory::Draft => "draft",
322        StatusCategory::Backlog => "backlog",
323        StatusCategory::Todo => "todo",
324        StatusCategory::InProgress => "in-progress",
325        StatusCategory::Done => "done",
326        StatusCategory::Cancelled => "cancelled",
327        StatusCategory::Unknown => "unknown",
328    }
329}
330
331/// A shipped default's option name.
332///
333/// The literals below are this file's own and non-blank, and they are validated by the
334/// one constructor a configured name goes through rather than beside it.
335fn shipped_column(name: &'static str) -> ColumnName {
336    ColumnName::try_from(name.to_owned()).expect("a shipped default names a board option")
337}
338
339/// The shipped default for one category, before this instance's configuration.
340fn shipped_default(category: StatusCategory) -> StatusTarget {
341    match category {
342        StatusCategory::Backlog => StatusTarget::Column(shipped_column("Backlog")),
343        StatusCategory::Todo => StatusTarget::Column(shipped_column("Todo")),
344        StatusCategory::InProgress => StatusTarget::Column(shipped_column("In Progress")),
345        StatusCategory::Done => StatusTarget::Closed(ClosedState::Completed),
346        StatusCategory::Cancelled => StatusTarget::Closed(ClosedState::NotPlanned),
347        StatusCategory::Draft | StatusCategory::Unknown => StatusTarget::Disabled,
348    }
349}
350
351/// This instance's complete category-to-target mapping, read in both directions.
352///
353/// One target per category, held at that category's own [`category_position`], so a
354/// category missing from the mapping, named twice in it, or filed out of order is a
355/// state this type cannot hold rather than one [`Self::target`] has to defend against.
356#[derive(Debug, Clone)]
357struct StatusMapping {
358    targets: [StatusTarget; CATEGORIES.len()],
359}
360
361impl StatusMapping {
362    fn resolve(
363        configured: BTreeMap<String, Option<StatusTargetConfig>>,
364        instance: &SourceName,
365    ) -> Result<Self, SourceError> {
366        let mut overrides: BTreeMap<&'static str, Option<StatusTargetConfig>> = BTreeMap::new();
367        for (key, value) in configured {
368            let category = CATEGORIES
369                .iter()
370                .find(|category| category_name(**category) == key)
371                .ok_or_else(|| SourceError::Config {
372                    message: format!(
373                        "status_mapping names {key:?}, which is not a status category of source \
374                         {instance}; the categories are {}",
375                        CATEGORIES
376                            .iter()
377                            .map(|category| category_name(*category))
378                            .collect::<Vec<_>>()
379                            .join(", ")
380                    ),
381                })?;
382            overrides.insert(category_name(*category), value);
383        }
384        // `CATEGORIES[position] == category` for every category — the crate's suite
385        // asserts it — so mapping the list in order fills each category's own slot.
386        let targets = CATEGORIES.map(|category| match overrides.remove(category_name(category)) {
387            None => shipped_default(category),
388            Some(None) => StatusTarget::Disabled,
389            Some(Some(StatusTargetConfig::Column(option))) => StatusTarget::Column(option),
390            Some(Some(StatusTargetConfig::Closed { closed })) => StatusTarget::Closed(closed),
391        });
392        let mapping = Self { targets };
393        for (index, category) in CATEGORIES.into_iter().enumerate() {
394            let StatusTarget::Column(option) = mapping.target(category) else {
395                continue;
396            };
397            if let Some(other) = CATEGORIES[..index].iter().find(|earlier| {
398                matches!(mapping.target(**earlier), StatusTarget::Column(name)
399                    if name.as_str().eq_ignore_ascii_case(option.as_str()))
400            }) {
401                return Err(SourceError::Config {
402                    message: format!(
403                        "status_mapping of source {instance} sends both {} and {} to the board \
404                         option {:?}; one option cannot read back as two categories",
405                        category_name(*other),
406                        category_name(category),
407                        option.as_str()
408                    ),
409                });
410            }
411        }
412        Ok(mapping)
413    }
414
415    fn target(&self, category: StatusCategory) -> &StatusTarget {
416        &self.targets[category_position(category)]
417    }
418
419    /// The category a board option name reports, or `None` when nothing maps to it.
420    fn category_of(&self, option: &str) -> Option<StatusCategory> {
421        CATEGORIES.into_iter().find(|category| {
422            matches!(self.target(*category), StatusTarget::Column(name)
423                if name.as_str().eq_ignore_ascii_case(option))
424        })
425    }
426}
427
428/// The one repository this source creates issues in.
429#[derive(Debug, Clone)]
430struct RepositoryTarget {
431    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
432    name: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only after `owner/name` validation in `new`.
433}
434
435impl RepositoryTarget {
436    fn parse(value: &str) -> Result<Self, SourceError> {
437        let (owner, name) = value.split_once('/').ok_or_else(|| SourceError::Config {
438            message: format!(
439                "repository must be spelled owner/name; {value:?} names no repository"
440            ),
441        })?;
442        if !valid_github_owner(owner) || !valid_github_repository_name(name) {
443            return Err(SourceError::Config {
444                message: format!(
445                    "repository must be spelled owner/name with a GitHub login and one \
446                     repository name; {value:?} is not"
447                ),
448            });
449        }
450        Ok(Self {
451            owner: owner.to_owned(),
452            name: name.to_owned(),
453        })
454    }
455
456    fn origin(&self) -> String {
457        format!("github.com/{}/{}", self.owner, self.name)
458    }
459}
460
461/// A source which reads GitHub afresh for every operation.
462pub struct GitHubProjectsSource {
463    /// This source's configured name, used both to tell a far end naming this source
464    /// from one naming a system it knows nothing about, and to name the instance a
465    /// status refusal is about.
466    name: SourceName,
467    owner: String, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after full GitHub-owner validation.
468    project_number: u32, // llmlint: ignore[invalid_states_unrepresentable] Private, constructed only by `new` after GraphQL-Int validation.
469    repository: Option<RepositoryTarget>,
470    endpoint: Url,
471    token: SecretString,
472    credential_name: String, // llmlint: ignore[invalid_states_unrepresentable] Private diagnostic value constructed only after environment-name validation.
473    statuses: StatusMapping,
474    client: Client,
475}
476
477impl GitHubProjectsSource {
478    /// Validate configuration and capture the named credential without exposing it.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`SourceError::Config`] for a configuration this instance cannot use and
483    /// [`SourceError::Auth`] when the named credential is missing or empty.
484    pub fn new(
485        name: &SourceName,
486        config: GitHubProjectsConfig,
487        secrets: &dyn SecretResolver,
488    ) -> Result<Self, SourceError> {
489        if !valid_github_owner(&config.owner) {
490            return Err(SourceError::Config {
491                message: "owner must be 1-39 ASCII letters, digits, or single hyphens, and cannot start or end with a hyphen".into(),
492            });
493        }
494        if config.project_number == 0 || config.project_number > i32::MAX as u32 {
495            return Err(SourceError::Config {
496                message: format!("project_number must be between 1 and {}", i32::MAX),
497            });
498        }
499        if !valid_environment_name(&config.token_env) {
500            return Err(SourceError::Config {
501                message: "token_env must be a valid environment-variable name".into(),
502            });
503        }
504        let repository = config
505            .repository
506            .as_deref()
507            .map(RepositoryTarget::parse)
508            .transpose()?;
509        let endpoint = Url::parse(&config.endpoint).map_err(|e| SourceError::Config {
510            message: format!("endpoint is not a valid URL: {e}"),
511        })?;
512        if endpoint.scheme() != "https"
513            && !(endpoint.scheme() == "http"
514                && endpoint
515                    .host_str()
516                    .is_some_and(|h| h == "127.0.0.1" || h == "localhost" || h == "::1"))
517        {
518            return Err(SourceError::Config {
519                message:
520                    "endpoint must use HTTPS (HTTP is accepted only for a loopback test server)"
521                        .into(),
522            });
523        }
524        let token = secrets.get(&config.token_env).filter(|token| !token.expose_secret().trim().is_empty()).ok_or_else(|| SourceError::Auth {
525            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),
526        })?;
527        Ok(Self {
528            name: name.clone(),
529            owner: config.owner,
530            project_number: config.project_number,
531            repository,
532            endpoint,
533            token,
534            credential_name: config.token_env,
535            statuses: StatusMapping::resolve(config.status_mapping, name)?,
536            client: Client::builder()
537                .user_agent("onetaskgraph")
538                .build()
539                .map_err(|e| SourceError::Config {
540                    message: format!("cannot build HTTP client: {e}"),
541                })?,
542        })
543    }
544
545    async fn graphql(&self, query: &str, variables: Value) -> Result<Value, SourceError> {
546        let response = self
547            .client
548            .post(self.endpoint.clone())
549            .bearer_auth(self.token.expose_secret())
550            .json(&json!({"query": query, "variables": variables}))
551            .send()
552            .await
553            .map_err(|e| SourceError::Unavailable {
554                message: format!("GitHub GraphQL request failed: {e}"),
555            })?;
556        let status = response.status();
557        let retry_after = response
558            .headers()
559            .get("retry-after")
560            .and_then(|v| v.to_str().ok())
561            .and_then(|v| v.parse().ok());
562        let exhausted = response
563            .headers()
564            .get("x-ratelimit-remaining")
565            .and_then(|v| v.to_str().ok())
566            == Some("0");
567        if status == StatusCode::TOO_MANY_REQUESTS || exhausted {
568            return Err(SourceError::RateLimited {
569                retry_after_seconds: retry_after,
570            });
571        }
572        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
573            return Err(SourceError::Auth {
574                message: format!(
575                    "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"
576                ),
577            });
578        }
579        if !status.is_success() {
580            return Err(SourceError::Unavailable {
581                message: format!("GitHub GraphQL returned HTTP {status}"),
582            });
583        }
584        let body: Value = response.json().await.map_err(|e| SourceError::Malformed {
585            message: format!("GitHub returned invalid JSON: {e}"),
586        })?;
587        let errors = body
588            .get("errors")
589            .map(|value| {
590                value.as_array().ok_or_else(|| SourceError::Malformed {
591                    message: "GitHub response errors is not an array".into(),
592                })
593            })
594            .transpose()?;
595        if let Some(errors) = errors.filter(|errors| !errors.is_empty()) {
596            let messages = errors
597                .iter()
598                .filter_map(|e| e.get("message").and_then(Value::as_str))
599                .collect::<Vec<_>>()
600                .join("; ");
601            let message = if messages.is_empty() {
602                "GitHub returned GraphQL errors".into()
603            } else {
604                messages
605            };
606            let normalized = message.to_ascii_lowercase();
607            if normalized.contains("resource not accessible") || normalized.contains("scope") {
608                return Err(SourceError::Auth {
609                    message: format!(
610                        "{message}; grant {} Projects and Issues read/write plus Pull requests read-only access for every repository represented on the board",
611                        self.credential_name
612                    ),
613                });
614            }
615            return Err(SourceError::Refused { message });
616        }
617        body.get("data")
618            .filter(|data| data.is_object())
619            .cloned()
620            .ok_or_else(|| SourceError::Malformed {
621                message: "GitHub response has no data object".into(),
622            })
623    }
624
625    // llmlint: ignore[boundary_inputs_validated] GitHub caps nested connections at 100 and
626    // GraphQL cannot independently page them inside the outer item page. This source page is
627    // deliberately bounded at that published maximum; the live drift journey exercises it.
628    async fn board_page(
629        &self,
630        items_after: Option<&str>,
631        items_first: u32,
632    ) -> Result<Value, SourceError> {
633        let data = self
634            .graphql(
635                graphql::BOARD,
636                json!({"owner":self.owner,"number":self.project_number,
637                       "first":items_first.min(MAX_PAGE_SIZE),"after":items_after,
638                       "nestedFirst":NESTED_PAGE_SIZE,"duplicates":true}),
639            )
640            .await?;
641        data.pointer("/owner/projectV2")
642            .filter(|v| !v.is_null())
643            .cloned()
644            .ok_or_else(|| SourceError::Refused {
645                message: format!(
646                    "GitHub project {}/{} was not found or is not visible to the token",
647                    self.owner, self.project_number
648                ),
649            })
650    }
651
652    /// Every item on the board, with the one board identity they all share.
653    async fn board(&self) -> Result<Board, SourceError> {
654        let mut after: Option<String> = None;
655        let mut items = Vec::new();
656        let mut board;
657        loop {
658            let page = self.board_page(after.as_deref(), MAX_PAGE_SIZE).await?;
659            for item in page
660                .pointer("/items/nodes")
661                .and_then(Value::as_array)
662                .ok_or_else(|| SourceError::Malformed {
663                    message: "GitHub project items.nodes is not an array".into(),
664                })?
665            {
666                if let Some(resolved) = self.resolve(item)? {
667                    items.push(resolved);
668                }
669            }
670            let info = page
671                .pointer("/items/pageInfo")
672                .ok_or_else(|| SourceError::Malformed {
673                    message: "GitHub project items have no pageInfo".into(),
674                })?;
675            let has_next = required_bool(info, "hasNextPage")?;
676            let next = has_next
677                .then(|| required_str(info, "endCursor"))
678                .transpose()?;
679            board = page.clone();
680            match next {
681                Some(next) => {
682                    validate_cursor_progress(after.as_deref(), next)?;
683                    after = Some(next.to_owned());
684                }
685                None => break,
686            }
687        }
688        Ok(Board {
689            id: required_str(&board, "id")?.to_owned(),
690            fields: board.get("fields").cloned().unwrap_or(Value::Null),
691            items,
692        })
693    }
694
695    /// One board item as this source reports it, or `None` for content it ignores.
696    ///
697    /// A pull request is neither a project nor a task — it is somebody's change, not a
698    /// unit of plan — and an item whose content the token cannot see has nothing to
699    /// report at all.
700    fn resolve(&self, item: &Value) -> Result<Option<Resolved>, SourceError> {
701        let content = item.get("content").ok_or_else(|| SourceError::Malformed {
702            message: "GitHub project item is missing content".into(),
703        })?;
704        if content.is_null() {
705            return Ok(None);
706        }
707        let content_kind = match required_str(content, "__typename")? {
708            "Issue" => ContentKind::Issue,
709            "DraftIssue" => ContentKind::DraftIssue,
710            _ => return Ok(None),
711        };
712        let field_values = item
713            .get("fieldValues")
714            .ok_or_else(|| SourceError::Malformed {
715                message: "GitHub project item is missing fieldValues".into(),
716            })?;
717        complete_connection(field_values, "project item field values")?;
718        let nodes = field_values
719            .get("nodes")
720            .and_then(Value::as_array)
721            .ok_or_else(|| SourceError::Malformed {
722                message: "GitHub project item fieldValues.nodes is not an array".into(),
723            })?;
724        if let Some(labels) = content.get("labels") {
725            complete_connection(labels, "content labels")?;
726        }
727        for field_value in nodes {
728            if let Some(labels) = field_value.get("labels") {
729                complete_connection(labels, "project item field labels")?;
730            }
731        }
732        let (body, slot) = metadata_body(optional_str(content, "body")?.map(str::to_owned))?;
733        let parent = optional_str(content.get("parent").unwrap_or(&Value::Null), "id")?
734            .map(|id| NativeId(id.to_owned()));
735        // A draft has no sub-issues to summarise, and GitHub's schema gives it no field
736        // to read one from; it is a task, and never a project.
737        let sub_issues = match content_kind {
738            ContentKind::Issue => sub_issue_total(content)?,
739            ContentKind::DraftIssue => 0,
740        };
741        let content_id = required_str(content, "id")?;
742        let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
743            message: format!("GitHub issue {content_id}: {message}"),
744        })?;
745        // Being a sub-issue wins outright, and no marker overrides it: an issue filed
746        // under a project is that project's task even when it has sub-issues of its own.
747        let kind = if parent.is_some() {
748            ItemKind::Task
749        } else if sub_issues > 0 || marked == Some(ItemKind::Project) {
750            ItemKind::Project
751        } else {
752            ItemKind::Task
753        };
754        let own_repository = content
755            .pointer("/repository/nameWithOwner")
756            .and_then(Value::as_str)
757            .map(|origin| Repository::try_from(format!("github.com/{origin}")))
758            .transpose()
759            .map_err(|message| SourceError::Malformed { message })?;
760        let repositories = if slot.contains_key(Repository::METADATA_KEY) {
761            Repository::from_metadata(&slot)
762                .map_err(|message| SourceError::Malformed { message })?
763        } else {
764            own_repository.clone().into_iter().collect()
765        };
766        Ok(Some(Resolved {
767            item_id: required_str(item, "id")?.to_owned(),
768            id: NativeId(content_id.to_owned()),
769            content_kind,
770            kind,
771            title: required_str(content, "title")?.to_owned(),
772            body: body.filter(|value| !value.is_empty()),
773            status: self.status(item, content)?,
774            labels: labels(content, nodes)?,
775            parent,
776            origin: text_field(nodes, ORIGIN_FIELD)?.filter(|value| !value.is_empty()),
777            url: optional_str(content, "url")?.map(str::to_owned),
778            created_at: optional_time(content, "createdAt")?,
779            updated_at: optional_time(content, "updatedAt")?,
780            own_repository,
781            repositories,
782            slot,
783        }))
784    }
785
786    /// The status one board item reports.
787    ///
788    /// The closed state decides the category and the `Status` option decides the name, so
789    /// a closed issue sitting in a "Shipped" column reports `done` named `Shipped`. A
790    /// closed issue whose reason is `DUPLICATE` or `REOPENED` reports `Unknown`: a
791    /// duplicate is not finished work, and calling it done is a lie the next copy would
792    /// write back. `REOPENED`-while-closed is a state this source can never produce, so
793    /// it is read permissively rather than refused — reads are faithful, and refusals
794    /// belong on writes.
795    fn status(&self, item: &Value, content: &Value) -> Result<Status, SourceError> {
796        let nodes = item
797            .pointer("/fieldValues/nodes")
798            .and_then(Value::as_array)
799            .expect("resolve validates fieldValues.nodes before mapping status");
800        let option = nodes
801            .iter()
802            .find(|value| value.pointer("/field/name").and_then(Value::as_str) == Some("Status"))
803            .map(|value| required_str(value, "name"))
804            .transpose()?;
805        let state = optional_str(content, "state")?;
806        if state == Some("CLOSED") {
807            let category = match optional_str(content, "stateReason")? {
808                None | Some("COMPLETED") => StatusCategory::Done,
809                Some("NOT_PLANNED") => StatusCategory::Cancelled,
810                Some(_) => StatusCategory::Unknown,
811            };
812            let fallback = match category {
813                StatusCategory::Done => "Done",
814                StatusCategory::Cancelled => "Cancelled",
815                _ => "Closed",
816            };
817            return Ok(Status {
818                category,
819                name: option.unwrap_or(fallback).to_owned(),
820            });
821        }
822        let name = option.unwrap_or("Open").to_owned();
823        Ok(Status {
824            category: self
825                .statuses
826                .category_of(&name)
827                .unwrap_or(StatusCategory::Unknown),
828            name,
829        })
830    }
831
832    /// The board Status option this write selects, or the refusal that says why not.
833    ///
834    /// For a column target the option is what the status *is*, so a board that has no such
835    /// option is a refusal naming the status and the instance. For a closed target the
836    /// issue's own state carries the category, and the option carries only the name a
837    /// reader reports — so an option spelled the way this status is spelled is selected
838    /// when the board has one, and nothing is refused when it does not.
839    fn column_for(
840        &self,
841        board: &Board,
842        status: &Status,
843        target: &StatusTarget,
844    ) -> Result<Option<(String, String)>, SourceError> {
845        let (wanted, required) = match target {
846            StatusTarget::Column(wanted) => (wanted.as_str(), true),
847            StatusTarget::Closed(_) => (status.name.as_str(), false),
848            StatusTarget::Disabled => return Ok(None),
849        };
850        let missing = |detail: &str| SourceError::Refused {
851            message: format!(
852                "status {} of source {} needs the board Status option {wanted:?}, and {detail};                  add that option to the board, or point status_mapping.{} of this source at one                  it has",
853                category_name(status.category),
854                self.name,
855                category_name(status.category)
856            ),
857        };
858        let Some(field) = Board::field(&board.fields, "Status")? else {
859            return if required {
860                Err(missing("this board has no Status field"))
861            } else {
862                Ok(None)
863            };
864        };
865        if required_str(field, "__typename")? != "ProjectV2SingleSelectField" {
866            return if required {
867                Err(missing(
868                    "this board's Status field is not a single-select field",
869                ))
870            } else {
871                Ok(None)
872            };
873        }
874        let option = field
875            .get("options")
876            .and_then(Value::as_array)
877            .and_then(|options| {
878                options.iter().find(|option| {
879                    option
880                        .get("name")
881                        .and_then(Value::as_str)
882                        .is_some_and(|name| name.eq_ignore_ascii_case(wanted))
883                })
884            });
885        match option {
886            None if required => Err(missing("this board does not have it")),
887            None => Ok(None),
888            Some(option) => Ok(Some((
889                required_str(field, "id")?.to_owned(),
890                required_str(option, "id")?.to_owned(),
891            ))),
892        }
893    }
894
895    /// This instance's target for a category, refusing one it has disabled.
896    ///
897    /// Nothing here mutates the board's option set to make room for a status. GitHub
898    /// documents `UpdateProjectV2FieldInput.singleSelectOptions` as *"provided values
899    /// overwrite existing options"*, so no addition is additive and a mistake destroys the
900    /// field and every item's status.
901    fn resolved_target(&self, category: StatusCategory) -> Result<StatusTarget, SourceError> {
902        let target = self.statuses.target(category).clone();
903        if target != StatusTarget::Disabled {
904            return Ok(target);
905        }
906        Err(SourceError::Refused {
907            message: if category == StatusCategory::Draft {
908                format!(
909                    "status draft is disabled for source {}: draft is incompatible with this \
910                     integration because GitHub draft issues cannot have sub-issues, and this \
911                     source stores a project's tasks as its issue's sub-issues",
912                    self.name
913                )
914            } else {
915                format!(
916                    "status {} is disabled for source {}; set status_mapping.{} of this source \
917                     to a board Status option name or to a closed state",
918                    category_name(category),
919                    self.name,
920                    category_name(category)
921                )
922            },
923        })
924    }
925
926    async fn set_item_field(
927        &self,
928        board_id: &str,
929        item_id: &str,
930        field_id: &str,
931        value: Value,
932    ) -> Result<(), SourceError> {
933        let data = self
934            .graphql(
935                graphql::UPDATE_FIELD,
936                json!({"input":{
937                    "projectId":board_id,"itemId":item_id,"fieldId":field_id,"value":value
938                }}),
939            )
940            .await?;
941        let returned = data
942            .pointer("/updateProjectV2ItemFieldValue/projectV2Item")
943            .ok_or_else(|| SourceError::Malformed {
944                message: "GitHub field update returned no project item".into(),
945            })?;
946        if required_str(returned, "id")? != item_id {
947            return Err(SourceError::Malformed {
948                message: "GitHub field update returned the wrong project item".into(),
949            });
950        }
951        Ok(())
952    }
953
954    async fn native_dependency_ids(&self, id: &NativeId) -> Result<Vec<String>, SourceError> {
955        let mut after: Option<String> = None;
956        let mut ids = Vec::new();
957        loop {
958            let data = self
959                .graphql(
960                    graphql::ISSUE_DEPENDENCIES,
961                    json!({"id":id.0,"first":MAX_PAGE_SIZE,"after":after}),
962                )
963                .await?;
964            let connection =
965                data.pointer("/node/blockedBy")
966                    .ok_or_else(|| SourceError::Malformed {
967                        message: "GitHub dependency response has no blockedBy connection".into(),
968                    })?;
969            ids.extend(
970                connection
971                    .get("nodes")
972                    .and_then(Value::as_array)
973                    .ok_or_else(|| SourceError::Malformed {
974                        message: "GitHub dependency response nodes is not an array".into(),
975                    })?
976                    .iter()
977                    .map(|value| required_str(value, "id").map(str::to_owned))
978                    .collect::<Result<Vec<_>, _>>()?,
979            );
980            let next = next_cursor(connection)?;
981            if let Some(next) = &next {
982                validate_cursor_progress(after.as_deref(), &next.0)?;
983            }
984            after = next.map(|cursor| cursor.0);
985            if after.is_none() {
986                return Ok(ids);
987            }
988        }
989    }
990
991    async fn dependencies(
992        &self,
993        id: &NativeId,
994        near_kind: ItemKind,
995        direction: Direction,
996        page: &PageRequest,
997    ) -> Result<Page<DependencyEdge>, SourceError> {
998        validate_page(page)?;
999        let limit = page.limit.min(MAX_PAGE_SIZE) as usize;
1000        let cursor = page.cursor.as_ref().map(|c| c.0.as_str());
1001        let recorded = recorded_offset(cursor, direction)?;
1002        // Asked for even in the recorded phase, whose page reads nothing from the
1003        // connection: `__typename` is what says whether this item has a native
1004        // relationship at all, and that is what decides which far ends the reserved key is
1005        // allowed to hold.
1006        let data = self
1007            .graphql(
1008                graphql::ISSUE_DEPENDENCIES,
1009                json!({"id":id.0,"first":page.limit.min(MAX_PAGE_SIZE),
1010                       "after":if recorded.is_some() {None} else {cursor}}),
1011            )
1012            .await?;
1013        let node =
1014            data.get("node")
1015                .filter(|v| !v.is_null())
1016                .ok_or_else(|| SourceError::Refused {
1017                    message: format!(
1018                        "GitHub item {} was not found or does not support dependencies",
1019                        id.0
1020                    ),
1021                })?;
1022        let connection_name = match direction {
1023            Direction::DependsOn => "blockedBy",
1024            Direction::DependedOnBy => "blocking",
1025        };
1026        // A draft has neither `blockedBy` nor `blocking`, so nothing it depends on can be
1027        // named natively and the reserved key may hold any far end. An issue's connections
1028        // hold issues, and this source reads them at the near item's own level.
1029        let natively_names = (required_str(node, "__typename")? == "Issue").then_some(near_kind);
1030        if let Some(offset) = recorded {
1031            return Ok(recorded_page(
1032                self.recorded_edges(id, near_kind, direction, natively_names)
1033                    .await?,
1034                offset,
1035                limit,
1036            ));
1037        }
1038        if natively_names.is_none() {
1039            return Ok(recorded_page(
1040                self.recorded_edges(id, near_kind, direction, natively_names)
1041                    .await?,
1042                0,
1043                limit,
1044            ));
1045        }
1046        let connection = node
1047            .get(connection_name)
1048            .ok_or_else(|| SourceError::Malformed {
1049                message: "GitHub dependency response is missing its connection".into(),
1050            })?;
1051        let nodes = connection
1052            .get("nodes")
1053            .and_then(Value::as_array)
1054            .ok_or_else(|| SourceError::Malformed {
1055                message: "GitHub dependency response nodes is not an array".into(),
1056            })?;
1057        // `from` depends on `to`, always. GitHub spells the same relationship from either
1058        // end — `blockedBy` lists what this item waits on, `blocking` lists what waits on
1059        // it — so the near item is `from` in one direction and `to` in the other.
1060        let items = nodes
1061            .iter()
1062            .map(|value| {
1063                let related = NativeId(required_str(value, "id")?.into());
1064                let related_kind = related_kind(value)?;
1065                let (from, to) = match direction {
1066                    Direction::DependsOn => (
1067                        DependencyEndpoint::from_native(id.clone(), near_kind),
1068                        DependencyEndpoint::from_native(related, related_kind),
1069                    ),
1070                    Direction::DependedOnBy => (
1071                        DependencyEndpoint::from_native(related, related_kind),
1072                        DependencyEndpoint::from_native(id.clone(), near_kind),
1073                    ),
1074                };
1075                Ok(DependencyEdge {
1076                    from,
1077                    to,
1078                    kind: DependencyKind::Blocks,
1079                })
1080            })
1081            .collect::<Result<Vec<_>, SourceError>>()?;
1082        let mut next = next_cursor(connection)?;
1083        if let Some(next) = &next {
1084            validate_cursor_progress(cursor, &next.0)?;
1085        }
1086        if next.is_none()
1087            && !self
1088                .recorded_edges(id, near_kind, direction, natively_names)
1089                .await?
1090                .is_empty()
1091        {
1092            next = Some(Cursor(format!("{RECORDED_CURSOR}0")));
1093        }
1094        Ok(Page { items, next })
1095    }
1096
1097    /// The edges this item records under [`DependencyEdge::RECORDED_KEY`], which is where
1098    /// a far end in another source has to live: no GitHub issue relationship can name one.
1099    ///
1100    /// Only forwards. The reverse of a recorded edge is derived from the far end, and this
1101    /// source never writes one down.
1102    ///
1103    /// The metadata lives in the item's own body slot, so reading it costs one board scan.
1104    /// That is why it happens once the native connection is spent rather than on every
1105    /// page.
1106    async fn recorded_edges(
1107        &self,
1108        id: &NativeId,
1109        near_kind: ItemKind,
1110        direction: Direction,
1111        natively_names: Option<ItemKind>,
1112    ) -> Result<Vec<DependencyEdge>, SourceError> {
1113        if direction != Direction::DependsOn {
1114            return Ok(Vec::new());
1115        }
1116        let Some(item) = self
1117            .board()
1118            .await?
1119            .items
1120            .into_iter()
1121            .find(|item| item.id == *id)
1122        else {
1123            return Ok(Vec::new());
1124        };
1125        DependencyEdge::recorded(&item.slot, id, near_kind, &self.name, natively_names)
1126            .map_err(|message| SourceError::Malformed { message })
1127    }
1128
1129    /// The configured repository's node id, or the refusal naming the field it needs.
1130    async fn repository_id(&self) -> Result<String, SourceError> {
1131        let repository = self
1132            .repository
1133            .as_ref()
1134            .ok_or_else(|| SourceError::Refused {
1135                message: format!(
1136                    "source {} has no repository configured, and a GitHub Projects board has no \
1137                 repository of its own to create an issue in; set repository: owner/name on \
1138                 this source",
1139                    self.name
1140                ),
1141            })?;
1142        let data = self
1143            .graphql(
1144                graphql::REPOSITORY,
1145                json!({"owner":repository.owner,"name":repository.name}),
1146            )
1147            .await?;
1148        let node = data
1149            .get("repository")
1150            .filter(|value| !value.is_null())
1151            .ok_or_else(|| SourceError::Refused {
1152                message: format!(
1153                    "GitHub repository {}/{} was not found or is not visible to the token",
1154                    repository.owner, repository.name
1155                ),
1156            })?;
1157        Ok(required_str(node, "id")?.to_owned())
1158    }
1159
1160    /// Create or update one board item, whichever kind it is.
1161    async fn write_item(
1162        &self,
1163        incoming: &Incoming<'_>,
1164        target: Option<&NativeId>,
1165        depends_on: &[DependencyEdge],
1166    ) -> Result<NativeId, SourceError> {
1167        let board = self.board().await?;
1168        let status_target = self.resolved_target(incoming.status.category)?;
1169        let column = self.column_for(&board, incoming.status, &status_target)?;
1170        let existing = target
1171            .map(|target| {
1172                board
1173                    .items
1174                    .iter()
1175                    .find(|item| item.id == *target)
1176                    .ok_or_else(|| SourceError::Refused {
1177                        message: format!("GitHub destination item {} was not found", target.0),
1178                    })
1179            })
1180            .transpose()?;
1181        let content_kind = existing.map_or(ContentKind::Issue, |item| item.content_kind);
1182        if content_kind == ContentKind::DraftIssue {
1183            if let StatusTarget::Closed(_) = status_target {
1184                return Err(SourceError::Refused {
1185                    message: format!(
1186                        "status {} of source {} closes the item's issue, and GitHub draft items \
1187                         have no open or closed state",
1188                        category_name(incoming.status.category),
1189                        self.name
1190                    ),
1191                });
1192            }
1193            if incoming.parent.is_some() {
1194                return Err(SourceError::Refused {
1195                    message: "GitHub draft items cannot be a project's sub-issue".into(),
1196                });
1197            }
1198        }
1199        match existing {
1200            Some(item) if content_kind == ContentKind::Issue => {
1201                if item.labels != incoming.labels {
1202                    return Err(SourceError::Refused {
1203                        message: "GitHub issue labels differ from the labels being written".into(),
1204                    });
1205                }
1206            }
1207            _ => {
1208                if !incoming.labels.is_empty() {
1209                    return Err(SourceError::Refused {
1210                        message: "GitHub items created by this destination carry no labels".into(),
1211                    });
1212                }
1213            }
1214        }
1215
1216        let own_repository = match existing {
1217            Some(item) => item.own_repository.clone(),
1218            None => self
1219                .repository
1220                .as_ref()
1221                .map(|repository| Repository::try_from(repository.origin()))
1222                .transpose()
1223                .map_err(|message| SourceError::Config { message })?,
1224        };
1225        let (native, fallback) = self
1226            .partition_edges(&board, incoming.kind, content_kind, depends_on)
1227            .await?;
1228        let slot = slot_metadata(incoming, own_repository.as_ref(), &fallback);
1229        let body = compose_body(incoming.content, &slot)?;
1230        // Read before anything is created, for the reason the field below is: a value
1231        // this destination cannot store has to refuse, and refusing after `createIssue`
1232        // would leave an issue behind that nothing asked for. The engine writes a
1233        // qualified id here; a caller handing this key anything else is told so rather
1234        // than having it silently stored as no origin at all.
1235        // llmlint: ignore[boundary_inputs_validated, changed_behavior_has_e2e] The qualified id's syntax is the engine's and not this plugin's to police: `GlobalId` is deliberately absent from the contract crate because a plugin never sees a qualified id (AGENTS.md), no plugin crate may depend on the engine to parse one, and `docs/metadata.md` says the contents of this key are what no plugin constructs or interprets. What this boundary owns is whether the value is a string its text field can hold, and that is what it checks.
1236        let origin = match incoming.metadata.get(ORIGIN_KEY) {
1237            None => "",
1238            Some(Value::String(origin)) => origin.as_str(),
1239            Some(other) => {
1240                return Err(SourceError::Refused {
1241                    message: format!(
1242                        "{ORIGIN_KEY} holds a qualified id spelled as a string, and this item's \
1243                         is {other}"
1244                    ),
1245                });
1246            }
1247        };
1248        // Resolved before anything is created: a board that cannot carry the copy origin
1249        // has to refuse the write, and refusing it after `createIssue` would leave an
1250        // issue behind that nothing asked for.
1251        let origin_field = match Board::field(&board.fields, ORIGIN_FIELD)? {
1252            Some(field) => {
1253                if required_str(field, "__typename")? != "ProjectV2Field" {
1254                    return Err(SourceError::Refused {
1255                        message: format!(
1256                            "GitHub board source-owned {ORIGIN_FIELD} field is not a text field"
1257                        ),
1258                    });
1259                }
1260                Some(required_str(field, "id")?.to_owned())
1261            }
1262            None if incoming.metadata.contains_key(ORIGIN_KEY) => {
1263                return Err(SourceError::Refused {
1264                    message: format!(
1265                        "GitHub board has no source-owned {ORIGIN_FIELD} text field, and the \
1266                         item carries {ORIGIN_KEY}; add a text field named {ORIGIN_FIELD} to \
1267                         the board"
1268                    ),
1269                });
1270            }
1271            None => None,
1272        };
1273
1274        let (content_id, item_id) = match existing {
1275            Some(item) => {
1276                self.update_existing(item, incoming, &body, &status_target)
1277                    .await?;
1278                (item.id.clone(), item.item_id.clone())
1279            }
1280            None => {
1281                self.create_and_file_issue(&board, incoming, &body, &status_target)
1282                    .await?
1283            }
1284        };
1285
1286        if let Some(field_id) = &origin_field {
1287            self.set_item_field(&board.id, &item_id, field_id, json!({"text":origin}))
1288                .await?;
1289        }
1290
1291        if let Some((field_id, option_id)) = column {
1292            self.set_item_field(
1293                &board.id,
1294                &item_id,
1295                &field_id,
1296                json!({"singleSelectOptionId":option_id}),
1297            )
1298            .await?;
1299        }
1300
1301        if content_kind == ContentKind::Issue {
1302            self.reparent(
1303                existing.and_then(|item| item.parent.clone()),
1304                &content_id,
1305                incoming.parent,
1306            )
1307            .await?;
1308            self.reconcile_blocked_by(&content_id, &native).await?;
1309        }
1310        Ok(content_id)
1311    }
1312
1313    /// Which far ends this item's own `blockedBy` relationship holds, and which it cannot.
1314    async fn partition_edges(
1315        &self,
1316        board: &Board,
1317        near_kind: ItemKind,
1318        near_content: ContentKind,
1319        depends_on: &[DependencyEdge],
1320    ) -> Result<(Vec<String>, Vec<DependencyEdge>), SourceError> {
1321        let mut native = Vec::new();
1322        let mut fallback = Vec::new();
1323        for edge in depends_on {
1324            let same_source = edge
1325                .to
1326                .source()
1327                .is_none_or(|source| source == self.name.as_str());
1328            let far_id = edge
1329                .to
1330                .id()
1331                .rsplit_once(':')
1332                .map_or(edge.to.id(), |(_, id)| id);
1333            let far = if same_source {
1334                Some(
1335                    board
1336                        .items
1337                        .iter()
1338                        .find(|item| item.id.0 == far_id)
1339                        .ok_or_else(|| SourceError::Refused {
1340                            message: format!("GitHub dependency item {far_id} was not found"),
1341                        })?,
1342                )
1343            } else {
1344                None
1345            };
1346            // The caller says which kind the far end is, and this board holds the far end
1347            // itself, so a disagreement is settled here rather than stored: recorded, the
1348            // wrong kind would read back as a cross-level edge that never existed; written
1349            // natively, it would name a relationship of a different level than the caller
1350            // asked for.
1351            if let Some(disagreeing) = far.filter(|far| far.kind != edge.to.kind) {
1352                return Err(SourceError::Refused {
1353                    message: format!(
1354                        "GitHub dependency item {far_id} is a {} of this board, and this item \
1355                         names it as a {}; record the kind it is",
1356                        disagreeing.kind.marker(),
1357                        edge.to.kind.marker()
1358                    ),
1359                });
1360            }
1361            // A draft has neither `blockedBy` nor `blocking`, so no edge of one is native
1362            // however the far end is spelled — and one classified native here would be
1363            // written nowhere at all, because a draft's native reconciliation never runs.
1364            let native_here = near_content == ContentKind::Issue
1365                && far.is_some_and(|far| {
1366                    far.content_kind == ContentKind::Issue && edge.to.kind == near_kind
1367                });
1368            if native_here {
1369                native.push(far_id.to_owned());
1370            } else {
1371                fallback.push(edge.clone());
1372            }
1373        }
1374        Ok((native, fallback))
1375    }
1376
1377    async fn update_existing(
1378        &self,
1379        item: &Resolved,
1380        incoming: &Incoming<'_>,
1381        body: &Option<String>,
1382        status_target: &StatusTarget,
1383    ) -> Result<(), SourceError> {
1384        let (operation, input, pointer) = match item.content_kind {
1385            ContentKind::DraftIssue => (
1386                graphql::UPDATE_DRAFT,
1387                json!({"draftIssueId":item.id.0,"title":incoming.title,"body":body}),
1388                "/updateProjectV2DraftIssue/draftIssue",
1389            ),
1390            ContentKind::Issue => (
1391                graphql::UPDATE_ISSUE,
1392                json!({"id":item.id.0,"title":incoming.title,"body":body,
1393                       "stateInput":state_input(status_target)}),
1394                "/updateIssue/issue",
1395            ),
1396        };
1397        let data = self.graphql(operation, json!({"input":input})).await?;
1398        let returned = data
1399            .pointer(pointer)
1400            .ok_or_else(|| SourceError::Malformed {
1401                message: "GitHub item update returned no item".into(),
1402            })?;
1403        if required_str(returned, "id")? != item.id.0 {
1404            return Err(SourceError::Malformed {
1405                message: "GitHub item update returned the wrong item".into(),
1406            });
1407        }
1408        Ok(())
1409    }
1410
1411    /// Creates one issue, files it on the board, and closes it when the status says so.
1412    ///
1413    /// Three calls rather than one: `createIssue` needs a repository and answers with an
1414    /// issue that is on no board, `addProjectV2ItemById` is what puts it there, and a
1415    /// closed status is a state of the issue rather than a field of the board item.
1416    async fn create_and_file_issue(
1417        &self,
1418        board: &Board,
1419        incoming: &Incoming<'_>,
1420        body: &Option<String>,
1421        status_target: &StatusTarget,
1422    ) -> Result<(NativeId, String), SourceError> {
1423        let repository_id = self.repository_id().await?;
1424        let data = self
1425            .graphql(
1426                graphql::CREATE_ISSUE,
1427                json!({"input":{
1428                    "repositoryId":repository_id,"title":incoming.title,"body":body
1429                }}),
1430            )
1431            .await?;
1432        let created = data
1433            .pointer("/createIssue/issue")
1434            .filter(|value| !value.is_null())
1435            .ok_or_else(|| SourceError::Malformed {
1436                message: "GitHub issue creation returned no issue".into(),
1437            })?;
1438        let content_id = NativeId(required_str(created, "id")?.to_owned());
1439        let added = self
1440            .graphql(
1441                graphql::ADD_TO_BOARD,
1442                json!({"input":{"projectId":board.id,"contentId":content_id.0}}),
1443            )
1444            .await?;
1445        let item = added
1446            .pointer("/addProjectV2ItemById/item")
1447            .filter(|value| !value.is_null())
1448            .ok_or_else(|| SourceError::Malformed {
1449                message: "GitHub board addition returned no project item".into(),
1450            })?;
1451        if let StatusTarget::Closed(_) = status_target {
1452            let closed = self
1453                .graphql(
1454                    graphql::UPDATE_ISSUE,
1455                    json!({"input":{"id":content_id.0,"stateInput":state_input(status_target)}}),
1456                )
1457                .await?;
1458            let returned =
1459                closed
1460                    .pointer("/updateIssue/issue")
1461                    .ok_or_else(|| SourceError::Malformed {
1462                        message: "GitHub item update returned no item".into(),
1463                    })?;
1464            if required_str(returned, "id")? != content_id.0 {
1465                return Err(SourceError::Malformed {
1466                    message: "GitHub item update returned the wrong item".into(),
1467                });
1468            }
1469        }
1470        Ok((content_id, required_str(item, "id")?.to_owned()))
1471    }
1472
1473    /// Move one issue under the project it now belongs to, or out of the one it left.
1474    async fn reparent(
1475        &self,
1476        held: Option<NativeId>,
1477        child: &NativeId,
1478        wanted: Option<&NativeId>,
1479    ) -> Result<(), SourceError> {
1480        if held.as_ref() == wanted {
1481            return Ok(());
1482        }
1483        if let Some(held) = &held {
1484            self.sub_issue(graphql::REMOVE_SUB_ISSUE, held, child, "removeSubIssue")
1485                .await?;
1486        }
1487        if let Some(wanted) = wanted {
1488            self.sub_issue(graphql::ADD_SUB_ISSUE, wanted, child, "addSubIssue")
1489                .await?;
1490        }
1491        Ok(())
1492    }
1493
1494    async fn sub_issue(
1495        &self,
1496        operation: &str,
1497        parent: &NativeId,
1498        child: &NativeId,
1499        root: &str,
1500    ) -> Result<(), SourceError> {
1501        let data = self
1502            .graphql(
1503                operation,
1504                json!({"input":{"issueId":parent.0,"subIssueId":child.0}}),
1505            )
1506            .await?;
1507        let issue =
1508            data.pointer(&format!("/{root}/issue"))
1509                .ok_or_else(|| SourceError::Malformed {
1510                    message: "GitHub sub-issue update returned no issue".into(),
1511                })?;
1512        let sub =
1513            data.pointer(&format!("/{root}/subIssue"))
1514                .ok_or_else(|| SourceError::Malformed {
1515                    message: "GitHub sub-issue update returned no sub-issue".into(),
1516                })?;
1517        if required_str(issue, "id")? != parent.0 || required_str(sub, "id")? != child.0 {
1518            return Err(SourceError::Malformed {
1519                message: "GitHub sub-issue update returned the wrong issues".into(),
1520            });
1521        }
1522        Ok(())
1523    }
1524
1525    async fn reconcile_blocked_by(
1526        &self,
1527        content_id: &NativeId,
1528        native: &[String],
1529    ) -> Result<(), SourceError> {
1530        let current = self.native_dependency_ids(content_id).await?;
1531        for (operation, far_id) in current
1532            .iter()
1533            .filter(|id| !native.contains(id))
1534            .map(|id| (graphql::REMOVE_BLOCKED_BY, id))
1535            .chain(
1536                native
1537                    .iter()
1538                    .filter(|id| !current.contains(id))
1539                    .map(|id| (graphql::ADD_BLOCKED_BY, id)),
1540            )
1541        {
1542            let data = self
1543                .graphql(
1544                    operation,
1545                    json!({"input":{"issueId":content_id.0,"blockingIssueId":far_id}}),
1546                )
1547                .await?;
1548            let root = if operation == graphql::ADD_BLOCKED_BY {
1549                "addBlockedBy"
1550            } else {
1551                "removeBlockedBy"
1552            };
1553            let issue =
1554                data.pointer(&format!("/{root}/issue"))
1555                    .ok_or_else(|| SourceError::Malformed {
1556                        message: "GitHub dependency update returned no issue".into(),
1557                    })?;
1558            let blocker = data
1559                .pointer(&format!("/{root}/blockingIssue"))
1560                .ok_or_else(|| SourceError::Malformed {
1561                    message: "GitHub dependency update returned no blocking issue".into(),
1562                })?;
1563            if required_str(issue, "id")? != content_id.0 || required_str(blocker, "id")? != far_id
1564            {
1565                return Err(SourceError::Malformed {
1566                    message: "GitHub dependency update returned the wrong issues".into(),
1567                });
1568            }
1569        }
1570        Ok(())
1571    }
1572}
1573
1574/// The board, and every item on it this source reports.
1575struct Board {
1576    id: String,
1577    fields: Value,
1578    items: Vec<Resolved>,
1579}
1580
1581impl Board {
1582    fn field<'a>(fields: &'a Value, name: &str) -> Result<Option<&'a Value>, SourceError> {
1583        complete_connection(fields, "project fields")?;
1584        let nodes = fields
1585            .get("nodes")
1586            .and_then(Value::as_array)
1587            .ok_or_else(|| SourceError::Malformed {
1588                message: "GitHub project fields.nodes is not an array".into(),
1589            })?;
1590        Ok(nodes
1591            .iter()
1592            .find(|field| field.get("name").and_then(Value::as_str) == Some(name)))
1593    }
1594}
1595
1596/// One board item, resolved into everything this source reports about it.
1597struct Resolved {
1598    item_id: String,
1599    id: NativeId,
1600    content_kind: ContentKind,
1601    kind: ItemKind,
1602    title: String,
1603    body: Option<String>,
1604    status: Status,
1605    labels: Vec<Label>,
1606    parent: Option<NativeId>,
1607    // llmlint: ignore[invalid_states_unrepresentable] The write side's reason, read back: this is the engine's qualified id, taken out of a board text field and handed on untouched. A newtype here would have this plugin define the syntax of an id `docs/metadata.md` says no plugin ever constructs or interprets.
1608    origin: Option<String>,
1609    url: Option<String>,
1610    created_at: Option<DateTime<Utc>>,
1611    updated_at: Option<DateTime<Utc>>,
1612    own_repository: Option<Repository>,
1613    repositories: Vec<Repository>,
1614    slot: BTreeMap<String, Value>,
1615}
1616
1617impl Resolved {
1618    /// The metadata a caller sees: their own keys, plus the copy origin this source keeps
1619    /// in a field of its own, and none of the three keys that are only an encoding.
1620    fn metadata(&self) -> BTreeMap<String, Value> {
1621        let mut metadata = self.slot.clone();
1622        metadata.remove(Repository::METADATA_KEY);
1623        metadata.remove(DependencyEdge::RECORDED_KEY);
1624        metadata.remove(ItemKind::METADATA_KEY);
1625        if let Some(origin) = &self.origin {
1626            metadata.insert(ORIGIN_KEY.to_owned(), Value::String(origin.clone()));
1627        }
1628        metadata
1629    }
1630
1631    fn task(&self) -> Task {
1632        Task {
1633            id: self.id.clone(),
1634            title: self.title.clone(),
1635            content: self.body.clone(),
1636            status: self.status.clone(),
1637            labels: self.labels.clone(),
1638            project: self.parent.clone(),
1639            url: self.url.clone(),
1640            created_at: self.created_at,
1641            updated_at: self.updated_at,
1642            metadata: self.metadata(),
1643            repositories: self.repositories.clone(),
1644        }
1645    }
1646
1647    fn project(&self) -> Project {
1648        Project {
1649            id: self.id.clone(),
1650            title: self.title.clone(),
1651            content: self.body.clone(),
1652            status: self.status.clone(),
1653            labels: self.labels.clone(),
1654            url: self.url.clone(),
1655            created_at: self.created_at,
1656            updated_at: self.updated_at,
1657            metadata: self.metadata(),
1658            repositories: self.repositories.clone(),
1659        }
1660    }
1661}
1662
1663/// The item being written, in the one shape both write methods reach.
1664struct Incoming<'a> {
1665    kind: ItemKind,
1666    title: &'a str,
1667    content: Option<&'a str>,
1668    status: &'a Status,
1669    labels: &'a [Label],
1670    metadata: &'a BTreeMap<String, Value>,
1671    repositories: &'a [Repository],
1672    parent: Option<&'a NativeId>,
1673}
1674
1675#[derive(Clone, Copy, PartialEq, Eq)]
1676enum ContentKind {
1677    DraftIssue,
1678    Issue,
1679}
1680
1681#[async_trait::async_trait]
1682impl TaskSource for GitHubProjectsSource {
1683    fn kind(&self) -> &'static str {
1684        KIND
1685    }
1686    fn capabilities(&self) -> Capabilities {
1687        Capabilities {
1688            projects: Support::Native,
1689            orphan_tasks: Support::Unsupported,
1690            filter_by_label: Support::Unsupported,
1691            filter_by_status: Support::Unsupported,
1692            search_title: Support::Unsupported,
1693            search_content: Support::Unsupported,
1694            task_dependencies: DependencySupport::BothDirections,
1695            project_dependencies: DependencySupport::BothDirections,
1696            max_page_size: MAX_PAGE_SIZE,
1697        }
1698    }
1699    async fn health(&self) -> Result<Health, SourceError> {
1700        let board = self.board_page(None, 1).await?;
1701        Ok(Health {
1702            reachable: true,
1703            detail: Some(format!(
1704                "reading GitHub project {}/{} ({})",
1705                self.owner,
1706                self.project_number,
1707                required_str(&board, "title")?
1708            )),
1709        })
1710    }
1711    async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
1712        Ok(self
1713            .board()
1714            .await?
1715            .items
1716            .iter()
1717            .find(|item| item.id == *id && item.kind == ItemKind::Task)
1718            .map(Resolved::task))
1719    }
1720    async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
1721        Ok(self
1722            .board()
1723            .await?
1724            .items
1725            .iter()
1726            .find(|item| item.id == *id && item.kind == ItemKind::Project)
1727            .map(Resolved::project))
1728    }
1729    async fn query_tasks(
1730        &self,
1731        _query: &TaskQuery,
1732        page: &PageRequest,
1733    ) -> Result<Page<Task>, SourceError> {
1734        validate_page(page)?;
1735        let tasks = self
1736            .board()
1737            .await?
1738            .items
1739            .iter()
1740            .filter(|item| item.kind == ItemKind::Task)
1741            .map(Resolved::task)
1742            .collect();
1743        Ok(offset_page(
1744            tasks,
1745            numeric_cursor(page.cursor.as_ref())?,
1746            page.limit.min(MAX_PAGE_SIZE) as usize,
1747        ))
1748    }
1749    async fn query_projects(
1750        &self,
1751        _query: &ProjectQuery,
1752        page: &PageRequest,
1753    ) -> Result<Page<Project>, SourceError> {
1754        validate_page(page)?;
1755        let projects = self
1756            .board()
1757            .await?
1758            .items
1759            .iter()
1760            .filter(|item| item.kind == ItemKind::Project)
1761            .map(Resolved::project)
1762            .collect();
1763        Ok(offset_page(
1764            projects,
1765            numeric_cursor(page.cursor.as_ref())?,
1766            page.limit.min(MAX_PAGE_SIZE) as usize,
1767        ))
1768    }
1769    async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
1770        validate_page(page)?;
1771        let offset = numeric_cursor(page.cursor.as_ref())?;
1772        let mut labels = self
1773            .board()
1774            .await?
1775            .items
1776            .into_iter()
1777            .flat_map(|item| item.labels)
1778            .fold(Vec::new(), |mut all, label| {
1779                if !all.iter().any(|x: &Label| x.id == label.id) {
1780                    all.push(label);
1781                }
1782                all
1783            });
1784        labels.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.0.cmp(&b.id.0)));
1785        Ok(offset_page(
1786            labels,
1787            offset,
1788            page.limit.min(MAX_PAGE_SIZE) as usize,
1789        ))
1790    }
1791    async fn task_dependencies(
1792        &self,
1793        id: &NativeId,
1794        direction: Direction,
1795        page: &PageRequest,
1796    ) -> Result<Page<DependencyEdge>, SourceError> {
1797        self.dependencies(id, ItemKind::Task, direction, page).await
1798    }
1799    async fn project_dependencies(
1800        &self,
1801        id: &NativeId,
1802        direction: Direction,
1803        page: &PageRequest,
1804    ) -> Result<Page<DependencyEdge>, SourceError> {
1805        self.dependencies(id, ItemKind::Project, direction, page)
1806            .await
1807    }
1808
1809    fn writes(&self) -> WriteSupport {
1810        WriteSupport::Supported
1811    }
1812
1813    async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
1814        self.write_item(
1815            &Incoming {
1816                kind: ItemKind::Task,
1817                title: &write.item.title,
1818                content: write.item.content.as_deref(),
1819                status: &write.item.status,
1820                labels: &write.item.labels,
1821                metadata: &write.item.metadata,
1822                repositories: &write.item.repositories,
1823                parent: write.item.project.as_ref(),
1824            },
1825            write.target.as_ref(),
1826            &write.depends_on,
1827        )
1828        .await
1829    }
1830
1831    async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
1832        self.write_item(
1833            &Incoming {
1834                kind: ItemKind::Project,
1835                title: &write.item.title,
1836                content: write.item.content.as_deref(),
1837                status: &write.item.status,
1838                labels: &write.item.labels,
1839                metadata: &write.item.metadata,
1840                repositories: &write.item.repositories,
1841                parent: None,
1842            },
1843            write.target.as_ref(),
1844            &write.depends_on,
1845        )
1846        .await
1847    }
1848}
1849
1850/// Where the recorded tail of a dependency walk resumes; see
1851/// [`GitHubProjectsSource::recorded_edges`].
1852const RECORDED_CURSOR: &str = "onetaskgraph.depends_on:";
1853
1854/// The board text field this source keeps a copy's origin in.
1855///
1856/// Named after the key it holds, and held to that name by the guard below rather than by
1857/// a reader noticing.
1858const ORIGIN_FIELD: &str = "onetaskgraph.origin";
1859
1860/// The metadata key that field holds.
1861///
1862/// The engine owns this key and spells it once as `GlobalId::ORIGIN_KEY`; a plugin never
1863/// constructs or interprets the qualified id it carries. This source names it only to
1864/// route it — a short, typed value belongs in a typed field rather than in the body slot
1865/// a caller's own prose shares.
1866///
1867/// Restated rather than imported, because no plugin crate may depend on the engine. What
1868/// keeps the two spellings one contract is `scripts/check-origin-key-spelling.sh`, a
1869/// target in `check`: it reads the engine's own literal and fails naming the file and the
1870/// line when a plugin's parts from it either way. Drift here has one symptom — a copy
1871/// that creates a second item every run instead of finding the one it wrote — and that is
1872/// too late to learn it.
1873const ORIGIN_KEY: &str = "onetaskgraph.origin";
1874
1875/// Where a recorded tail resumes, refusing a cursor no walk in `direction` reported.
1876///
1877/// The reserved key holds forward edges and nothing else — the reverse of a recorded edge
1878/// is derived from the far end, never written down on the near item — so only a forward
1879/// walk ever reports one of these cursors. A reverse read carrying one is resuming a walk
1880/// it did not come from, and it is told so rather than answered with an empty page that
1881/// reads as a walk which ended.
1882fn recorded_offset(
1883    cursor: Option<&str>,
1884    direction: Direction,
1885) -> Result<Option<usize>, SourceError> {
1886    cursor
1887        .and_then(|cursor| cursor.strip_prefix(RECORDED_CURSOR))
1888        .map(|offset| {
1889            if direction != Direction::DependsOn {
1890                return Err(SourceError::Config {
1891                    message: format!(
1892                        "{RECORDED_CURSOR}{offset} resumes recorded forward edges, which a \
1893                         reverse dependency read never issues; resume it in the direction \
1894                         that reported it"
1895                    ),
1896                });
1897            }
1898            offset.parse().map_err(|_| SourceError::Config {
1899                message: format!("{RECORDED_CURSOR}{offset} is not a recorded-edge cursor"),
1900            })
1901        })
1902        .transpose()
1903}
1904
1905fn recorded_page(edges: Vec<DependencyEdge>, offset: usize, limit: usize) -> Page<DependencyEdge> {
1906    let mut page = offset_page(edges, offset, limit.max(1));
1907    page.next = page
1908        .next
1909        .map(|cursor| Cursor(format!("{RECORDED_CURSOR}{}", cursor.0)));
1910    page
1911}
1912
1913/// The kind of one issue reached through a dependency connection.
1914///
1915/// The same three questions the board scan asks, over the fields the dependency document
1916/// selects: a sub-issue is a task, and anything else with sub-issues or the marker is a
1917/// project.
1918fn related_kind(value: &Value) -> Result<ItemKind, SourceError> {
1919    let parent = optional_str(value.get("parent").unwrap_or(&Value::Null), "id")?;
1920    if parent.is_some() {
1921        return Ok(ItemKind::Task);
1922    }
1923    let (_, slot) = metadata_body(optional_str(value, "body")?.map(str::to_owned))?;
1924    let id = required_str(value, "id")?;
1925    let marked = ItemKind::from_metadata(&slot).map_err(|message| SourceError::Malformed {
1926        message: format!("GitHub issue {id}: {message}"),
1927    })?;
1928    let sub_issues = sub_issue_total(value)?;
1929    Ok(if sub_issues > 0 || marked == Some(ItemKind::Project) {
1930        ItemKind::Project
1931    } else {
1932        ItemKind::Task
1933    })
1934}
1935
1936/// The `IssueStateUpdateInput` one status target asks for.
1937///
1938/// `stateInput` and `state` are mutually exclusive on `UpdateIssueInput`, and only this
1939/// one is ever sent. A non-terminal status always asks for `OPEN`, which is what reopens
1940/// a currently-closed issue: without that the item would read back `Unknown` and a copy
1941/// would report a change forever.
1942fn state_input(target: &StatusTarget) -> Value {
1943    match target {
1944        StatusTarget::Closed(reason) => json!({"value":"CLOSED","stateReason":reason.reason()}),
1945        StatusTarget::Column(_) | StatusTarget::Disabled => json!({"value":"OPEN"}),
1946    }
1947}
1948
1949/// The metadata one write stores in the item's body slot.
1950///
1951/// The typed fields travel as themselves, so the three reserved keys are rebuilt here
1952/// rather than carried: the kind marker so an empty project stays readable, the
1953/// repository list only when it is not exactly the issue's own repository, and the far
1954/// ends no relationship here can name.
1955fn slot_metadata(
1956    incoming: &Incoming<'_>,
1957    own_repository: Option<&Repository>,
1958    fallback: &[DependencyEdge],
1959) -> BTreeMap<String, Value> {
1960    let mut metadata = incoming.metadata.clone();
1961    metadata.remove(ORIGIN_KEY);
1962    metadata.insert(
1963        ItemKind::METADATA_KEY.to_owned(),
1964        Value::String(incoming.kind.marker().to_owned()),
1965    );
1966    let derivable = own_repository
1967        .map(|own| incoming.repositories == [own.clone()])
1968        .unwrap_or(incoming.repositories.is_empty());
1969    if derivable {
1970        metadata.remove(Repository::METADATA_KEY);
1971    } else {
1972        metadata.insert(
1973            Repository::METADATA_KEY.to_owned(),
1974            Value::Array(
1975                incoming
1976                    .repositories
1977                    .iter()
1978                    .map(|repository| Value::String(repository.as_str().to_owned()))
1979                    .collect(),
1980            ),
1981        );
1982    }
1983    if fallback.is_empty() {
1984        metadata.remove(DependencyEdge::RECORDED_KEY);
1985    } else {
1986        metadata.insert(
1987            DependencyEdge::RECORDED_KEY.to_owned(),
1988            Value::Array(
1989                fallback
1990                    .iter()
1991                    .map(|edge| json!({"id":edge.to.id(),"kind":edge.to.kind}))
1992                    .collect(),
1993            ),
1994        );
1995    }
1996    metadata
1997}
1998
1999fn labels(content: &Value, field_values: &[Value]) -> Result<Vec<Label>, SourceError> {
2000    let direct = optional_nodes(content.get("labels"), "content labels")?;
2001    let field = field_values
2002        .iter()
2003        .find_map(|value| value.get("labels"))
2004        .map(|labels| optional_nodes(Some(labels), "field labels"))
2005        .transpose()?
2006        .flatten();
2007    let labels = direct
2008        .into_iter()
2009        .flatten()
2010        .chain(field.into_iter().flatten())
2011        .map(|v| {
2012            Ok(Label {
2013                id: NativeId(required_str(v, "id")?.to_owned()),
2014                name: required_str(v, "name")?.to_owned(),
2015                color: optional_str(v, "color")?.map(str::to_owned),
2016            })
2017        })
2018        .collect::<Result<Vec<_>, SourceError>>()?
2019        .into_iter()
2020        .fold(Vec::new(), |mut labels, label| {
2021            if !labels.iter().any(|x: &Label| x.id == label.id) {
2022                labels.push(label);
2023            }
2024            labels
2025        });
2026    Ok(labels)
2027}
2028
2029fn text_field(field_values: &[Value], name: &str) -> Result<Option<String>, SourceError> {
2030    let Some(node) = field_values
2031        .iter()
2032        .find(|node| node.pointer("/field/name").and_then(Value::as_str) == Some(name))
2033    else {
2034        return Ok(None);
2035    };
2036    Ok(optional_str(node, "text")?.map(str::to_owned))
2037}
2038
2039fn valid_github_owner(owner: &str) -> bool {
2040    !owner.is_empty()
2041        && owner.len() <= 39
2042        && !owner.starts_with('-')
2043        && !owner.ends_with('-')
2044        && !owner.contains("--")
2045        && owner
2046            .bytes()
2047            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2048}
2049
2050/// GitHub's repository-name grammar: 1-100 ASCII letters, digits, `-`, `_` or `.`, and
2051/// neither of the two names a path segment already means.
2052fn valid_github_repository_name(name: &str) -> bool {
2053    !name.is_empty()
2054        && name.len() <= 100
2055        && name != "."
2056        && name != ".."
2057        && name
2058            .bytes()
2059            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2060}
2061
2062fn valid_environment_name(name: &str) -> bool {
2063    let mut bytes = name.bytes();
2064    bytes
2065        .next()
2066        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
2067        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
2068}
2069
2070/// How many sub-issues one issue has.
2071///
2072/// `Issue.subIssuesSummary` is `SubIssuesSummary!` and its `total` is `Int!`, so an
2073/// absent or non-integer one is a response this source cannot read — and reading it as
2074/// zero would classify a project as a task, which is exactly the mistake the marker
2075/// exists to keep from happening quietly.
2076fn sub_issue_total(issue: &Value) -> Result<u64, SourceError> {
2077    let summary = issue
2078        .get("subIssuesSummary")
2079        .ok_or_else(|| SourceError::Malformed {
2080            message: "GitHub issue is missing subIssuesSummary".into(),
2081        })?;
2082    summary
2083        .get("total")
2084        .and_then(Value::as_u64)
2085        .ok_or_else(|| SourceError::Malformed {
2086            message: "GitHub issue subIssuesSummary.total is not an unsigned integer".into(),
2087        })
2088}
2089
2090fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, SourceError> {
2091    value
2092        .get(field)
2093        .and_then(Value::as_str)
2094        .ok_or_else(|| SourceError::Malformed {
2095            message: format!("GitHub response is missing string field {field}"),
2096        })
2097}
2098
2099/// The slot's delimiters, which `docs/metadata.md` settles once for every source that
2100/// needs one — Linear spells them too, in its own description field.
2101///
2102/// Restated rather than shared, because a plugin crate depends on the contract crate and
2103/// nothing else of this workspace. `scripts/check-metadata-slot-encoding.sh`, a target in
2104/// `check`, is what keeps the two one encoding: drift is otherwise quiet, since each
2105/// source round-trips its own writes perfectly well under its own spelling.
2106const METADATA_OPEN: &str = "<!-- onetaskgraph.metadata\n";
2107const METADATA_CLOSE: &str = "\n-->";
2108
2109/// The visible body and the metadata slot at the end of it.
2110///
2111/// The encoding is the one `docs/metadata.md` settles for Linear, which is where its
2112/// reasons are. Only a comment at the very end is a slot; one in the middle is a person's
2113/// own content and is left alone.
2114fn metadata_body(
2115    body: Option<String>,
2116) -> Result<(Option<String>, BTreeMap<String, Value>), SourceError> {
2117    let Some(body) = body else {
2118        return Ok((None, BTreeMap::new()));
2119    };
2120    let Some(start) = body.rfind(METADATA_OPEN) else {
2121        return Ok((Some(body), BTreeMap::new()));
2122    };
2123    let encoded_start = start + METADATA_OPEN.len();
2124    let Some(relative_end) = body[encoded_start..].find(METADATA_CLOSE) else {
2125        return Err(SourceError::Malformed {
2126            message: "unterminated onetaskgraph metadata slot in GitHub issue body".into(),
2127        });
2128    };
2129    let encoded_end = encoded_start + relative_end;
2130    if !body[encoded_end + METADATA_CLOSE.len()..].trim().is_empty() {
2131        return Ok((Some(body), BTreeMap::new()));
2132    }
2133    let metadata = serde_json::from_str(&body[encoded_start..encoded_end]).map_err(|error| {
2134        SourceError::Malformed {
2135            message: format!(
2136                "invalid canonical JSON in GitHub issue onetaskgraph metadata slot: {error}"
2137            ),
2138        }
2139    })?;
2140    let visible = body[..start].trim_end();
2141    Ok(((!visible.is_empty()).then(|| visible.to_owned()), metadata))
2142}
2143
2144fn compose_body(
2145    content: Option<&str>,
2146    metadata: &BTreeMap<String, Value>,
2147) -> Result<Option<String>, SourceError> {
2148    let visible = content.unwrap_or_default();
2149    if metadata.is_empty() {
2150        return Ok((!visible.is_empty()).then(|| visible.to_owned()));
2151    }
2152    let encoded = serde_json::to_string(metadata).map_err(|error| SourceError::Malformed {
2153        message: error.to_string(),
2154    })?;
2155    Ok(Some(if visible.is_empty() {
2156        format!("{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2157    } else {
2158        format!("{visible}\n\n{METADATA_OPEN}{encoded}{METADATA_CLOSE}")
2159    }))
2160}
2161
2162fn required_bool(value: &Value, field: &str) -> Result<bool, SourceError> {
2163    value
2164        .get(field)
2165        .and_then(Value::as_bool)
2166        .ok_or_else(|| SourceError::Malformed {
2167            message: format!("GitHub response is missing boolean field {field}"),
2168        })
2169}
2170fn optional_str<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, SourceError> {
2171    match value.get(field) {
2172        None | Some(Value::Null) => Ok(None),
2173        Some(value) => value
2174            .as_str()
2175            .map(Some)
2176            .ok_or_else(|| SourceError::Malformed {
2177                message: format!("GitHub response field {field} is not a string or null"),
2178            }),
2179    }
2180}
2181fn optional_nodes<'a>(
2182    connection: Option<&'a Value>,
2183    name: &str,
2184) -> Result<Option<&'a Vec<Value>>, SourceError> {
2185    match connection {
2186        None | Some(Value::Null) => Ok(None),
2187        Some(value) => value
2188            .get("nodes")
2189            .and_then(Value::as_array)
2190            .map(Some)
2191            .ok_or_else(|| SourceError::Malformed {
2192                message: format!("GitHub {name}.nodes is not an array"),
2193            }),
2194    }
2195}
2196fn complete_connection(connection: &Value, name: &str) -> Result<(), SourceError> {
2197    let page_info = connection
2198        .get("pageInfo")
2199        .ok_or_else(|| SourceError::Malformed {
2200            message: format!("GitHub {name} has no pageInfo"),
2201        })?;
2202    if required_bool(page_info, "hasNextPage")? {
2203        return Err(SourceError::Malformed {
2204            message: format!(
2205                "GitHub {name} exceeds the supported nested connection size of {NESTED_PAGE_SIZE}"
2206            ),
2207        });
2208    }
2209    Ok(())
2210}
2211fn optional_time(value: &Value, field: &str) -> Result<Option<DateTime<Utc>>, SourceError> {
2212    optional_str(value, field)?
2213        .map(|timestamp| {
2214            timestamp.parse().map_err(|error| SourceError::Malformed {
2215                message: format!("GitHub response field {field} is not a timestamp: {error}"),
2216            })
2217        })
2218        .transpose()
2219}
2220fn validate_page(page: &PageRequest) -> Result<(), SourceError> {
2221    if page.limit == 0 {
2222        Err(SourceError::Config {
2223            message: "page limit must be at least 1".into(),
2224        })
2225    } else {
2226        Ok(())
2227    }
2228}
2229fn next_cursor(connection: &Value) -> Result<Option<Cursor>, SourceError> {
2230    let page = connection
2231        .get("pageInfo")
2232        .filter(|value| value.is_object())
2233        .ok_or_else(|| SourceError::Malformed {
2234            message: "GitHub connection is missing pageInfo".into(),
2235        })?;
2236    if required_bool(page, "hasNextPage")? {
2237        let cursor = required_str(page, "endCursor")?;
2238        validate_cursor_progress(None, cursor)?;
2239        Ok(Some(Cursor(cursor.into())))
2240    } else {
2241        Ok(None)
2242    }
2243}
2244fn validate_cursor_progress(previous: Option<&str>, next: &str) -> Result<(), SourceError> {
2245    if next.is_empty() || previous == Some(next) {
2246        Err(SourceError::Malformed {
2247            message: "GitHub pagination cursor is empty or did not advance".into(),
2248        })
2249    } else {
2250        Ok(())
2251    }
2252}
2253fn numeric_cursor(cursor: Option<&Cursor>) -> Result<usize, SourceError> {
2254    cursor.map_or(Ok(0), |c| {
2255        c.0.parse().map_err(|_| SourceError::Config {
2256            message: "page cursor is invalid".into(),
2257        })
2258    })
2259}
2260fn offset_page<T>(mut items: Vec<T>, offset: usize, limit: usize) -> Page<T> {
2261    if offset > items.len() {
2262        return Page::last(vec![]);
2263    }
2264    let tail = items.split_off(offset);
2265    let mut selected = tail;
2266    let next = (selected.len() > limit).then(|| Cursor((offset + limit).to_string()));
2267    selected.truncate(limit);
2268    Page {
2269        items: selected,
2270        next,
2271    }
2272}