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