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