A stateless onetaskgraph source over one GitHub Projects v2 board.
A board is a container of projects, not a project. Its own title,
shortDescription and readme are never read as an item's fields and are never
written: nothing in this source can rename the board a user configured.
A project is an issue and its tasks are that issue's sub-issues. GitHub's schema
decides that: Issue exposes parent, subIssues and subIssuesSummary, and
DraftIssue exposes none of them. Creating an issue needs a repositoryId, and a
board has none, so [GitHubProjectsConfig::repository] names the one repository this
source creates its project and task issues in; a write without it is refused naming
the field.
A document is an ordinary issue whose title begins [DESIGN_TITLE_PREFIX]. A
board has no document type and nothing but issues to hold one in, so the title is the
discriminator and it is the whole of it. The title this source reports is the one a
person wrote, with the prefix taken off — the same way the metadata slot is taken off
the body so content is what the person wrote — and writing a document puts the prefix
back, so a round trip returns the title that went in.
Telling a document from a project from a task. The design prefix is read first:
a document is never a project and never a task, whatever sub-issues it has or does not
have. Only then does the rest apply — a board issue is a project when either it has
sub-issues or it carries [ItemKind::METADATA_KEY]; otherwise it is a task. A
sub-issue is always a task, whatever it carries. The marker is sufficient and never
necessary: it is what makes an empty project — the state a project copy passes
through between creating the project and filing its first task — readable as a
project, while the sub-issue arm lets a person author a project on the board by hand
with no knowledge of this product's metadata at all. Reading the prefix later than the
sub-issue rule would make a design issue with no sub-issues an empty project, which is
exactly the state that rule exists to catch. Pull requests are neither a project nor a
task nor a document and are ignored.
Where an entity is, is a link. Every project, task and document this source reports
carries a [Location::Url] naming the issue's own web address — the same address the
url field already reports, in the shape that says a reader can open it. That is the
contrast the location contract exists for: a reader holding an entity from this source
is handed something to link to and one holding an entity from a folder of Markdown is
handed a path, and neither has to know which plugin answered. It does not replace or
derive from url; that field goes on reporting what it always reported.
Where metadata lives. Short typed things go to typed fields and native relations:
status to the board's Status single-select and the issue's own state, the copy
origin to a source-owned onetaskgraph.origin text field, and dependencies to
blockedBy and to sub-issue links. Unbounded caller JSON goes in a trailing
<!-- onetaskgraph.metadata ... --> comment at the end of the issue body — the same
encoding docs/metadata.md settles for Linear, not a second one. A ProjectV2 text
field is length-bounded and shortDescription is capped at 300 characters, which is
why neither can hold a caller's own prose.
Status. status_mapping is per-instance configuration from a status category to
null, a board Status option name, or a closed state of completed or
not-planned. Nothing here ever calls updateProjectV2Field: that mutation's
singleSelectOptions overwrites a field's option set, so no addition is additive
and a mistake destroys every item's status. A status this board cannot represent is a
refusal naming the status and the instance instead.
done closes the issue by default because GitHub derives subIssuesSummary.completed
and the board's own Sub-issues progress field from closed sub-issues: a plan whose
finished tasks were only moved to a "Done" column would read 0% complete forever.
What this source declares, field by field
One verdict per field of [Capabilities], and what Native means when this source
says it. Proven means a shared journey drives it against the real
binary over this source's own row in crates/onetaskgraph/tests/e2e/fixtures.rs, and
every_row_declares_exactly_what_its_plugin_reports is what keeps this list and
capabilities from parting.
| Field | Verdict |
|---|---|
projects |
Supported and proven, and the one predicate here that is pushed down rather than applied in process: a task's project is the issue it is a sub-issue of, so a listing scoped to one asks that issue for its own sub-issues. This is the field that was declared and then not applied, which silently returned another project's tasks. |
documents |
Supported and proven. A board holds issues, so a document is one: the issue whose title begins [DESIGN_TITLE_PREFIX]. Reads, filters and paging answer on exactly the terms a task read does, and a write puts the prefix back. |
orphan_tasks |
Supported and proven. A task issue with no parent is in no project. |
filter_by_label |
Supported and proven, over the issue's own labels. |
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. |
search_title |
Supported and proven, over Issue.title. |
search_content |
Supported and proven, over the visible body — the trailing metadata comment is not part of it. |
task_dependencies |
Supported and proven, in both directions: blockedBy and blocking. |
project_dependencies |
Supported and proven, in both directions, over the same two connections, because a project here is an issue. |
max_page_size |
Supported and proven. [MAX_PAGE_SIZE], GitHub's own connection maximum. |
Nothing here is unsupported. documents is not a predicate — it says this source has
documents, which it does — and the three facts behind the uniform Native on the
predicates beside it are recorded below rather than re-derived, because a reader who
takes Native to mean the remote service filters will read that uniformity as a
lie.
First, the plugin contract defines Support::Native as the source applies this
predicate itself, and says nothing about where it applies it. What the declaration
promises the engine is capability rule 1 — a predicate declared Native is applied
— so that the engine may push it down and apply nothing of its own.
Second, this source can keep that promise for every predicate at no additional API cost, because whichever of the three reads below answers a query has already read every item that query could keep before it filters anything. Filtering those items is in-process work over data already in hand.
Third, no predicate but projects could be pushed into the API even if that were
wanted, and projects is pushed down: ProjectV2.items takes first and after and
offers no filter argument of any kind, GitHub's issue search offers no qualifier for a
label set, a status column or a substring of a body, and its title qualifier matches
tokens where this source — and the local Markdown source beside it — match substrings,
so pushing a search down would silently narrow the answer. What a project filter has
instead is a relationship: a project's tasks are that issue's sub-issues, and asking
the issue for them is both cheaper and exact. So there is one predicate this source
applies by asking a narrower question, six it applies in process, and none it is unable
to apply. Declaring one Unsupported would make the engine compensate for work this
source has already done, and declaring projects native while ignoring the filter
(which this source once did) silently returns another project's tasks, because the
engine trusts the declaration and applies nothing locally.
The three ways this source reaches an item, and what each costs
A board read is charged for what its nested connections could return rather than for what was asked, so one whole-board read costs the same whether the question was about one project or about all of them. That is why a question about one project is never answered by reading the board:
| The question | What is sent | What it costs |
|---|---|---|
| one item, by its own id | [graphql::ISSUE] — node(id:) |
the item |
| one project's tasks or documents | [graphql::SUB_ISSUES] — that issue's own subIssues |
that project |
| which projects this board holds | [graphql::SEARCH_ISSUES] — an issue search scoped to the board |
the board's issues, without their board items |
| every task, every document, every label | [graphql::BOARD] — the board's own items |
the board |
The board half of an issue — its board item's id, its Status option and this
source's origin text field — rides along on Issue.projectItems in the first three, so
an item reached any of those ways resolves through the same
[GitHubProjectsSource::resolve] the board walk uses and reports the same title, the
same status, the same labels and the same qualified id. An issue with no entry for
this board is not this source's to report, which is what keeps an id naming another
repository's issue from being answered as an item of this board.
The board's own Labels field is not selected in those three, and nothing is lost by
that. The board half they read is a fragment on Issue, and an issue's labels are
already selected one level up, on the issue itself. A board's Labels field is not one
anybody fills in: it is a built-in ProjectV2FieldType, it is absent from
ProjectV2CustomFieldType so no project can create one, and ProjectV2FieldValue —
the whole of what updateProjectV2ItemFieldValue accepts — offers no way to write one.
For Issue content it is the issue's own labels, so selecting it beside them unions a
set with itself. [graphql::BOARD] still selects it and must: a board item's content
may be a DraftIssue, which has no labels of its own to select instead. The four ways
an item is reached are held to reporting one label set by
an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached in
tests/plugin.rs, which drives each of the four documents against the fixture board.
The last row is still the board's own item connection, and deliberately: a draft board item is not an issue, so no search and no node read can reach one, and the reads that have to answer for the whole board are the ones whose cost is the board's size anyway.
What a read may return is capped too, and that cap is on the document rather than on
the board. GitHub limits the number of nodes one query may return to
[NODE_COUNT_LIMIT] and refuses a query above that before executing it: the answer is
an error naming the connection the count crossed at, not a slow or a partial result.
Every board this source reads is refused the same way, so no board is too big for these
documents and none is small enough to save one that is over.
The count is arithmetic over the document's own text: each connection contributes the
first: it asks for, counts multiply down a nested path and sum across sibling
paths. Those are GitHub's published rules and this workspace does not
restate them — github-graphql-node-count implements them, and
[worst_case_node_count] under [largest_page_sizes] is where every node count here
comes from. every_document_this_source_sends_stays_under_githubs_node_limit, in
tests/node_count.rs, recomputes every document in [graphql::DOCUMENTS] from that
same text on every run and fails naming any that reaches the limit — so a connection
added to a shared fragment is caught there rather than by GitHub.
What decides those counts is the page sizes: [MAX_PAGE_SIZE] on the outer page,
NESTED_PAGE_SIZE on the connections hanging off one item, and
BOARD_ITEMS_PAGE_SIZE on an issue's board memberships. $nestedFirst is spent twice
down one path of a board read, so that constant is effectively squared there, which is
why it is the one the limit is most sensitive to.
nodeCount and cost are two numbers against two limits, and none of this is about
the second. nodeCount is the one above: the most nodes one query may return,
checked per query. cost is rate-limit points, metered per hour across everything one
credential does; it is what the two limiters [Limiter] tells apart meter, and a
document under [NODE_COUNT_LIMIT] says nothing about it.
Where a read-after-write guarantee comes from, since a search index cannot supply
one. GitHub's issue search is eventually consistent and answers a write made moments
ago with the value from before it. Resolving a node id is not, so a read by id and a
project's own sub-issues are already current. What closes the gap for the search is
[GitHubProjectsSource::created]: every read this source answers is completed with
what this process itself wrote, so an item created seconds ago is reported whether or
not GitHub's index has caught up. Nothing else is remembered, nothing is written down,
and the record dies with the process.
Filtering happens before paging, so a page of a filtered result is a page of the survivors rather than the survivors of a page. Label and text matching answer the same question the same way the local Markdown source's do, so one cross-source expectation holds for both.
Required checks use only the local fixture server; the ignored credentialed lane verifies the current schema, then drives every field of the table above against the real board. It builds its own fixture there — two projects, one task filed under each, one filed under neither, a label on one of the three and a closed status on another — because that shape is what tells an honoured predicate from an ignored one: a board holding a single project answers a project filter the same way whether or not this source applies it, which is exactly how the defect above went unseen.
That lane writes only to the board GH_PROJECTS_OWNER and GH_PROJECTS_NUMBER name,
and only into the repository GH_PROJECTS_REPOSITORY names, and skips — as it does
without GH_PROJECTS_TOKEN — when any of them is absent. Requiring both to be
nominated is what keeps a credentialed write lane off a board and a repository nobody
nominated; it never asks GitHub which project was updated most recently. Before it
starts, the lane also clears any item titled — and any repository label named — the way
it titles and names its own artifacts, which is self-healing after an interrupted run:
a process killed between its writes and its cleanup leaves artifacts the next run
removes.
GitHub has two rate limiters and this source is refused by both, so nothing here
treats them as one thing. The primary budget is the hourly allowance gh api rate_limit reports; the secondary limiter is a burst limiter over content-generating
requests, and nothing reports it. Which one refused decides the operator's next step,
so [Limiter] is a type rather than a detail, and it is what [MIN_MUTATION_INTERVAL_MS],
[GitHubProjectsSource::board_cache] and [GitHubProjectsSource::graphql] each answer
one part of.