Expand description
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 |
| which board item one issue is, past the page that came with it | graphql::ISSUE_BOARD_ITEMS — that issue’s own projectItems | one issue’s memberships |
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. That connection comes back a
page at a time, at BOARD_ITEMS_PAGE_SIZE, so the entry for this board is looked for
on the page in hand and — only if that page reports more of the connection — in the
last row’s read of that one issue’s memberships, resumed from the page’s own cursor and
walked to exhaustion. 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; and because the page is where the search starts rather than
where it ends, that answer is one about a connection read to exhaustion and never about
an unread page. Nothing costs the extra read but an issue on more boards than a page
holds: an issue this board really does not hold reports no next page, so its
memberships are already exhausted where they arrived.
No document here selects the board’s own Labels field, and nothing is lost by
that. An item’s labels are read from its content alone, wherever that content is
reached: the three documents above select Issue.labels on the fragment, and
graphql::BOARD selects the same connection on the ... on Issue arm of its
content. 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. So GitHub derives
it from the content, for every content type it exists on, and there is nothing it can
hold that the content does not already say: for an Issue it is that issue’s own
labels, so selecting it beside them unions a set with itself.
A draft loses nothing by that either, which is the reasoning this paragraph once had
backwards. DraftIssue exposes no labels field, and by the three schema facts above
it cannot carry a board Labels value to be derived from one — so a draft has nothing
to select and nothing to lose, and reports no labels at all. A PullRequest item is
discarded by GitHubProjectsSource::resolve before labels are read. Both halves are
held to that by tests in tests/plugin.rs: the four ways an item is reached report one
label set, and that set is the fixture issue’s own, by
an_item_reports_the_same_labels_title_status_and_id_however_it_is_reached; and a board
item whose content is a draft reports an empty set, by
a_board_item_whose_content_is_a_draft_reports_no_labels_at_all. The absence of the
selection is held over graphql::DOCUMENTS by
no_document_selects_the_boards_own_labels_field.
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 the page of an issue’s board memberships a read carries.
$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.
BOARD_ITEMS_PAGE_SIZE is small for a reason of its own, recorded beside it: what a
page of memberships misses is recovered by one further read rather than refused, so it
buys a bound every read pays for at the price of a request only a multi-board issue
pays.
nodeCount and cost are two numbers against two limits, and both are computed
offline here — per document, one document at a time. nodeCount is the one above: the
most nodes one query may return, checked per query and bounded by NODE_COUNT_LIMIT.
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 still says nothing about its price. worst_case_point_cost is
that second number, and tests/point_cost.rs pins every document in
graphql::DOCUMENTS at what it costs — there being no per-call point ceiling to hold
one under, the pin itself is the check. The credentialed lane reconciles both figures
against GitHub’s own, off a probe it already sends.
What is pinned that way is a per-document price and never a session’s. The record in
session-cost.md measures the two quantities a whole session can be counted in offline —
requests and worst-case nodes — and neither is points. What one whole session
consumes of the hourly point allowance is observable only from a credentialed run’s own
x-ratelimit-* headers, which is what accounting fills its per-budget figures from
and what tests/live.rs prints at the end of every run.
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.
The fixture-server tests above run wherever this crate is selected; the credentialed lane runs in the same required check, beside them, and can fail it — it 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.
§What a session of requests costs, and where the report is
This source records every request it sends into accounting::Accounting, at
send_once — the one place a request leaves this crate, which is why a read path added
later is counted without anybody remembering to count it. That is the whole of what this
crate adds to the arrangement; accounting is where what a record carries, how a
session’s spend is arrived at, and what it deliberately does not know are set out.
What one whole session of the live journey costs, counted that way against this crate’s
loopback fixture board, is written down in session-cost.md beside this crate — with the
reduction it came out of, and with what it does and does not say about rate-limit points.
GitHubProjectsSource::accounting is the read: a snapshot to hold and compare, which
accounting::Session::report renders the session report from. It is on the ordinary
code path — no environment variable, no feature, no build configuration — because an
instrument nobody switches on measures nothing, and
Plugin::build_recording_into is how a caller making its own calls beside this
source’s counts the whole session rather than this source’s share. The credentialed lane
in tests/live.rs does exactly that, and prints the report at the end of every run,
passed or failed.
A live session refuses to start unless the account can afford it. Before it does any
of the work it exists to do, the journey makes one request — GET /rate_limit, which
GitHub documents as not counting against the REST rate limit and which answers both of
its budgets at once — and starts only if, for each of them, what remains minus this
session’s estimated cost is still at least
onetaskgraph_live::RETAINED_BUFFER — twenty per cent — of that budget’s whole
allowance. A session that cannot declines: it did not run, so it is
neither a pass nor a failing assertion, and it says which budget was short, that budget’s
limit, what remained, the estimate, the buffer and when it resets — then stops, without
waiting for the budget to come back. The estimate is derived offline from
tests/fixtures/session-cost.txt and a cost model stated in tests/journey/budget.rs,
which is also where the published rule that model rests on is cited; the accounting
above records the gate’s own read like any other request, and
accounting::Session::report prints the estimate beside what the session really spent.
GitHub is the authority on both of its own numbers, and the credentialed lane goes and
asks it. Everything above computes nodeCount and cost offline from a document’s own
text, which is what lets it run on every platform and on a pull request from a fork with
no credential — and that is what actually stops a regression merging. But an offline
arithmetic can only ever agree with itself: if GitHub changes its rules, this workspace
goes on computing the old answer and nothing notices. So tests/live.rs reconciles them.
GitHub’s schema exposes rateLimit(dryRun: true), whose nodeCount is “the maximum
number of nodes this query may return” and whose cost is what that document would
spend, both for a document without executing it, and the lane asks it for every query
document this source sends, under the largest bindings this source sends, and fails when
GitHub’s figure and worst_case_node_count or worst_case_point_cost disagree. A
mutation is skipped, because rateLimit is a field of Query and cannot be asked about
one; the offline pins still cover it. It records what those calls reported about the
account’s own allowance, because whether asking is free is a thing to observe rather than
to assume. Two quantities, not one: NODE_COUNT_LIMIT bounds nodeCount per query,
and cost is metered against an hourly allowance the accounting above reads off a
credentialed run’s own response headers.
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.
Modules§
- accounting
- What a session of requests to GitHub cost, counted rather than argued about.
- graphql
- Exact GraphQL query documents issued by this plugin.
Structs§
- Column
Name - The name of a
Statussingle-select option on the board. - GitHub
Projects Config - Configuration for one GitHub Projects v2 board.
- GitHub
Projects Source - A source which reads GitHub afresh for every operation.
- Pacing
Config - How fast this source writes, and how long it waits out a rate-limit refusal.
- Plugin
- Factory for
GitHubProjectsSource.
Enums§
- Closed
State - The two closed states this product can mean.
- Node
Count Error - Why a document’s node count could not be computed.
- Status
Target Config - Where one status category lands on this board.
Constants§
- CATEGORIES
- Every status category, in the order the vocabulary declares them.
- CONTENT_
CREATION_ PER_ HOUR - The same ceiling as GitHub publishes it per hour, which this source does not pace
at. See
MIN_MUTATION_INTERVAL_MSfor why the per-minute bound is the one that governs; it is pinned beside its sibling so the gate would notice either one moving. - CONTENT_
CREATION_ PER_ MINUTE - GitHub’s published ceiling on content-generating requests, per minute.
- DESIGN_
TITLE_ PREFIX - The issue-title prefix that makes a board issue a document.
- KIND
- The registry name for this plugin.
- MAX_
PACING_ MS - The largest any pacing setting may be, in milliseconds.
- MAX_
PAGE_ SIZE - GitHub’s maximum connection page size.
- MIN_
MUTATION_ INTERVAL_ MS - Shortest interval between two content-creating mutations, in milliseconds.
- NODE_
COUNT_ LIMIT - The most nodes any one document this source sends may be asked to return.
- PRIMARY_
WORDINGS - The wordings GitHub answers an exhausted primary budget with.
- RETRY_
BACKOFF_ MS - First wait when a rate-limit refusal carries no hint; each further wait doubles it.
- RETRY_
BUDGET_ MS - Total time one call may spend waiting out rate limits before it reports a failure.
- SECONDARY_
WORDINGS - The wordings GitHub answers a secondary rate limit with.
Functions§
- category_
position - Where one category sits in
CATEGORIES; see that list for what this pins. - largest_
page_ sizes - The largest value this source can bind to each page-size variable its documents name.
- node_
count - The most nodes
documentcould be asked to return undervariables. - worst_
case_ node_ count - The most nodes
documentcould be asked to return, by GitHub’s published rules. - worst_
case_ point_ cost - The most rate-limit points one call of
documentcould spend, by GitHub’s published rules.
Type Aliases§
- Variables
- The integer bound each page-size variable a document names is given, keyed by
variable name without the leading
$.