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