How Prism reads GitHub (and what the token needs)
Written for whoever owns your GitHub org and whoever issues the credential. Short version: your users never generate GitHub traffic, the background sync is a single serial rate-capped job, and the token it needs is read-only.
For the procedure of turning the source on, see Take a source live. This page is what to hand your platform and security teams before you get there.
1. Users never touch GitHub
When someone asks Prism a question, the agent answers from a local read-only copy of the GitHub fields it needs, held in Prism's own Postgres. It does not call GitHub at query time for counts, rankings or per-person breakdowns.
One optional exception, off by default: the live drill-down
(mcps.githubLive.enabled), which reads a specific pull request on demand —
never a population. It is bounded on purpose: enumeration stops after three
pages, and one turn may open a limited number of distinct pull requests before
further reads are refused. See Limits.
2. The sync is one background job
A single scheduled job refreshes that copy — by default every 6 hours. It runs one request in flight at a time: no parallelism, no fan-out, no burst. The GitHub and Jira syncs are offset so they never run together.
3. It's incremental
After a one-time initial load (INGEST_BACKFILL_DAYS, default 90), every
subsequent run pulls only what changed since the last run, using GitHub's
updated: search qualifier — normally a small delta of a few pages.
Issues reach back a year, once the engine is ingesting GitHub
ingest.backfillDays sets how far back the pull request walk goes. In
ingest.engine.mode: authoritative (and for the shadow copy in shadow) the
issue walk has a floor of its own: it reaches back at least 365 days,
whatever ingest.backfillDays says. Lowering ingest.backfillDays cannot
shorten it.
On a source the engine has not ingested before. The floor is applied only
where the engine finds no recorded issue horizon. On an install where the
legacy GitHub lane has already run, that lane's horizon is already in the
shared ingest_state row, the engine reads it, and flipping to
authoritative therefore keeps the depth you already had rather than
deepening to a year. ingest.github.rewalkFrom is the lever that recovers it —
see §9 for what a re-sweep costs.
365 days is a floor on the issue walk, not a cap. Set ingest.backfillDays
to 365 or more and the issue lane walks the whole window with the pull-request
lane — at 1095 both reach back three years. The manifest figure decides how
deep the issue lane goes only while it is the deeper of the two.
That is a deliberate answer to a question people ask constantly and could not get: how much of the backlog is still open, year to date. A window of 90 or 180 days does not reach January from August, so the answer came back correctly labelled as covering less than was asked for — honest, and no use. A year covers year-to-date on any date, including 31 December.
It is affordable because the walk skips what it does not need. The two searches
share one calendar-day-at-a-time walk, and a day older than the pull-request
window is walked by the issue search alone, with no pull-request search
over it and no GraphQL enrichment, which is the expensive half of a busy day.
What that costs: the issue search pages per 100 results, so a deep day is one
search request per organisation per 100 issues updated that day — one for a
quiet day, two at the density the sizing measurement saw (14,386 issues over 90
days is about 160 a day), at most ten before the walk subdivides the bucket.
At the default cap of 30 requests per minute that is roughly 9–18 extra
minutes for one organisation on the default 90-day pull-request window (275
extra days at one to two requests each; the measured density sits at the top of
that range), proportionally less on a 180-day one, and nothing at all once
ingest.backfillDays reaches 365 — there are no days the issue search walks
alone then, and the whole walk is already the one the
sizing table covers. It is proportional
to the number of organisations, like the rest of the walk.
Two consequences worth knowing before you plan a first install:
- Lowering
ingest.backfillDaysfor a fast first pass no longer shortens the issue walk. 7 days of pull requests still comes with 365 days of issues, which on its own is roughly 12–24 minutes per organisation at the default cap (358 issue-only days at one to two requests each, by the same arithmetic as above). Raising it past 365 does still lengthen the issue walk, because the manifest figure is a floor and not a cap — atingest.backfillDays: 1095both lanes walk three years, and the issue lane costs a full bucket per day, exactly as the pull-request lane does. - Under
ingest.engine.mode: off(the default before 0.10.0), none of this applies. The legacy lane walksingest.backfillDaysfor issues and pull requests alike, exactly as it always has.
And one asymmetry to know about, because nothing labels it yet: the link
between an issue and the pull request that closes it is collected from the
pull request side, so it exists only for pull requests inside
ingest.backfillDays. Counts, ages and states of older issues are complete;
"is this issue linked to a pull request" is not, for issues whose only pull
request is older than the pull-request window. Raise ingest.backfillDays if
that question matters to you as much as the backlog one does.
4. It's scoped, lightweight, and read-only
Prism reads, and stores, only these:
| Field | Source | Why |
|---|---|---|
| Repo, PR number, state, title | PR object | Grouping, keys, display |
| Author login | PR object | Attribute output to a developer |
| Created / merged / closed timestamps | PR object | "Merged in window", cycle time |
| Additions, deletions, changed files | GraphQL PullRequest | PR size as an effort proxy — counts only, never the diff |
| Reviewer login, review outcome, timestamp | GraphQL PullRequest.reviews | Review load, time to first review — outcome and time only, never the review text |
| Ready-for-review timestamp | GraphQL ReadyForReviewEvent | When the review clock starts on a draft |
| Issue repo + number, state, state reason, timestamps | /search/issues (type:issue) | Bug-backlog questions; open vs. closed, completed vs. dropped |
| Issue author login, assignees | same | Who reported it, who is carrying it |
| Issue title, labels, comment count | same | Display, classification (priority is a label) — the count only, never the comment text |
| Issue type | same | Bug questions across both classification axes — your organisation's own type names (Task, Bug, …), stored verbatim as it wrote them |
| Which issues a PR closes | GraphQL closingIssuesReferences | Tie a fix back to the bug it closed |
| Commit author email + the login GitHub matched it to | GraphQL PullRequest.commits | Join a Jira assignee (known by email) to a GitHub author (known by login) — off unless you list your domains, see below |
What Prism does not read, and does not need:
- Source code, file contents or diffs. It takes the numbers, never the code.
- Review or comment bodies. Outcome and timestamp only.
- Any write. No creating or editing pull requests, comments, statuses or branches.
- Admin, org settings, secrets, Actions, packages or webhooks.
What your GitHub must expose for people to be correlated
Everything above is about pull requests, reviews and issues, and none of it depends on this. This part is about people, and it is a prerequisite rather than a tuning option: no setting on our side substitutes for it.
Every GitHub record Prism stores names a person by their GitHub login and by nothing else — author, reviewer, assignee, issue reporter. No GitHub dataset carries an email address. Jira, by contrast, knows a person by their corporate email address, and Agent Router spend is resolved to a person through your directory. So something has to say that a login and an address are the same person, and there are exactly three places that can come from. An installation needs at least one of them, and on a stock 0.11 install it has route C without doing anything.
| Route | What it needs from you | If it is absent |
|---|---|---|
| A — your directory carries the login | The source you designate as the organizational context stream holds a GitHub login per person, beside their email | Prism cannot invent it. Nothing else it reads knows who a login belongs to |
| B — the commit-author harvest | Developers commit with a corporate address, GitHub matched that address to an account, the address is not a @users.noreply.github.com one, and you have listed the domain in ingest.github.identityEmailDomains | No email-to-login evidence is ever collected, so there is nothing to build a crosswalk from later either |
| C — the profile sweep (0.11, on by default) | Your members publish an address on their GitHub profile, and your credential carries Members: Read. Nothing else — it runs on the ingest tick, so in shadow and authoritative alike (install-wide ingest.engine.mode: off renders no tick and therefore no sweep), and needs no identityEmailDomains (empty admits every published address; a value you set narrows the sweep to those domains) | The sweep records nothing, and route C contributes nothing. It is the route most installations will actually be resolving through, so this is worth checking rather than assuming |
Route C is the one a stock install has, and route A is the one to plan for — it is the record once you designate a stream, and the only route that resolves somebody who has published no address anywhere. They add up rather than compete.
Route B is not the fallback it looks like. It is off until you name domains,
it reads addresses out of commit metadata rather than out of your own
organisation's records, and it does not run at all while GitHub is
authoritative on the ingest engine — the default since 0.10.0. Anything below
that describes "what a GitHub instance yields on its own" is now route C's job;
route B is a supplement to it, on an install that has deliberately turned it on.
The two ADDRESS routes converge on a requirement that is easy to miss: B and C resolve a person only where the address they find is one your designated directory already holds. A perfect sweep against a directory spelling the domain differently resolves nobody. If your yield is low, compare the domains first — a local-part mismatch is rare and a domain mismatch is not.
Route A is the exception, and it is most of why it is worth planning for: a directory that carries the login states the fact outright, so nothing has to match on an address at all.
What you lose if none of the three holds. Not "some answers get less accurate" — these have no answer at all, and Prism says so rather than producing one:
- anything that puts a person's GitHub work beside their Jira work — pull requests merged against issues closed, review load against story points;
- anything that puts a person's GitHub work beside their Agent Router spend;
- every team-shaped GitHub question — a team is a property of a person in your directory, so with no route from a login to a person there is no route from a login to a team either.
GitHub-only answers are unaffected: counts, rankings, cycle time, review latency and the issue backlog all work, grouped by GitHub login. What you do not get is a person behind that login.
The estate-wide blocker to check for first. GitHub's "Keep my email
address private" setting rewrites every commit that user makes to a
@users.noreply.github.com address. Such an address resolves to a login
perfectly well, which is exactly why Prism drops it: it joins to nothing a
person is known by anywhere else. An organisation where that setting is on by
default harvests zero pairs, and no configuration on our side changes that.
The same applies to a GitHub Enterprise Server instance running the feature,
which issues the equivalent addresses under its own host name.
Find out before you commit to a route. prism-ingest check github measures
route B against your own instance — how many sampled commit authors carry an
address at all, how many of those GitHub resolved to an account, and how many
fall inside the domains you configured — as counts, without recording a single
address. Zero in the first of those and route B is dead on this instance
whatever else you set. See Self test for where the verdict
appears, and the sections below for what each number means.
It does not yet measure route C, which is the one on by default — so there
is no way to predict the sweep's yield before turning it on, and the place to
read what it actually produced is the self test's identity crosswalk line
afterwards. Tracked; until it lands, treat a low github_login:observed count
as the measurement.
Email addresses: nothing is recorded until you say which domains may be
That is route B, and Prism derives it from the commits it already fetches: a git commit carries its author's email address, and GitHub tells us which account it matched that address to. It records none of it by default.
# values.yaml
ingest:
github:
identityEmailDomains: "" # empty (the default): no address is recorded
Set it to your own domain(s) — acme.com, acme-labs.com; a leading @ is
fine — and Prism records an address only when all of these hold:
- the domain is one you listed. Subdomains are not implied: list
contractor.acme.comseparately if you want it; - GitHub resolved the address to an account. An address it could not place is
a string somebody typed into
git config, and Prism will not guess who it belongs to; - and it is not one of GitHub's own
@users.noreply.github.comaddresses.
What lands is evidence, not a decision: a table of "this address was seen against this login, this many times". An address seen against two different logins stays two rows — the disagreement is preserved rather than resolved, because nothing here can tell which of the two is the person. Prism records the pairs and acts on none of them by itself: nothing is renamed, nothing is merged, and no record you keep elsewhere is changed.
Measure before you turn it on. How much this yields depends entirely on
your instance: whether your developers commit with their corporate address, and
whether GitHub has that address registered against their account.
prism-ingest check github reports exactly that — how many sampled commit
authors carry an address, how many are in your domains, and how many resolve to
an account — as counts, without recording a single address. The
Self test page shows the verdict.
Turning it on reaches back. The first run after you set the domains walks the whole backfill window again, so the people already in your mirror are seen — without it the harvest would only ever notice someone the next time they opened a pull request, and the table would look empty on an install that has been running for months. It costs one extra walk and re-fetches pull requests you already have, which changes nothing. Adding a domain later does the same thing again, for the same reason.
Prism tracks this per day, per organisation: a day is recorded as harvested only once it has actually been read for authors. The harvest rides the same GraphQL call as PR sizes and reviews, so a day that call failed on is a day whose authors were never read — it stays unharvested, and a later run picks it up. Nothing has to be reset by hand.
A day can also be harvested but not whole: GitHub answers with the pull request and withholds its commit list. What happens next depends on why.
- Refused (the repository is one your token can search but not read): the
day is counted in
identity_partial_daysand is not re-walked, because no re-run crosses that boundary. The fix is widening the token's repository access, then a one-off re-sweep over the affected dates — see Widen a source's scope. Unlike the count above, this one does not fall on its own. - Busy (a timeout, a rate limit, anything transient): the day stays unharvested and the next scheduled run reads it. Nothing to do.
A reason Prism does not recognise is treated as the second kind, deliberately: retrying costs one extra walk, while assuming the worst would write off evidence that was there to be read. A day that keeps failing that way is retried a few times and then becomes a floor of its own, so a re-walk can never go on for ever.
Where these numbers live. They are in each run's log, and durably in the
ingest_state row for the github source — identity_uncovered_days (still to
be harvested), identity_partial_days (harvested, not whole) and
identity_floor_reasons (why). There is no admin screen for them yet, so
reading them outside the log means querying the database. If the backfill window
is being re-walked every run, identity_uncovered_days is the count still
outstanding: falling to zero over a few runs is the sweep completing normally.
It does not run in authoritative engine mode. If GitHub is running
authoritative — by the install-wide ingest.engine.mode, or by
ingest.github.engineMode, the per-source value that decides this source on its
own — it is ingested by the manifest engine, and GitHub's manifest declares no
commit-author step. Pull requests, issues and reviews are unaffected, and this
harvest collects no commit-author evidence: ingest.github.identityEmailDomains
has no effect on it for as long as that mode is on. It is not inert for GitHub —
the profile sweep (route C above) runs on the ingest tick whatever mode GitHub
is in, and the same setting narrows it. (The engine itself is no longer
without the capability — GitLab's manifest declares one, and the same setting
gates it there. This is a statement about GitHub's manifest, not the engine's.) The ingest says so in its log every run and prism-ingest check github reports it as identity.harvest_runs_in_this_mode: false.
That is an accepted consequence of the flip, not a defect waiting on a fix.
Prism keeps no directory of its own: the people in your organisation come from
the source you designate as your organizational context stream, which is the
designated route to a directory. The crosswalk still folds in any evidence this
harvest has left behind, so the effect is exactly this — an install running
GitHub authoritative resolves people to GitHub logins through the profile
sweep and the organizational stream, without the commit-author route. So it is
a choice between two supported shapes rather than a loss: keep GitHub on off
or shadow if you want the commit-author evidence to go on accruing, and see
Flip a source to the ingest engine for the flip, the rollback,
and what else changes with it.
Every run then says what it took and what it refused, in its own log:
identity: this run saw 42 email/login pair(s) in 318 commit author sighting(s)
and dropped 96 (domain_not_allowed 71, no_account 19, noreply 6); the mirror now
holds 210 pair(s). Evidence only — identity_observations; nothing resolves a
pair to a person.
"Sightings" counts the addresses it kept, and the drops are counted beside it — so the two do not add up to every commit author it looked at, and are not meant to.
5. What the token needs
A fine-grained token, or a GitHub App, is strongly preferred — see the warning under "classic" below. The permission list is the same for both; what differs is how the credential is configured and whose visibility decides its scope. A fine-grained token scoped to All repositories covers an organisation of any size; an App is preferable at any size because the grant is the scope rather than a person's visibility, and because it carries no user seat and higher rate limits — see Widen a source's scope, which is where the App's three values go and why it is not a token you paste.
Changing this credential's reach does not change the history already mirrored. Re-scoping a token from a hand-picked list to All repositories, or swapping a token for an App installed across the organisation, takes effect from the next sweep onward. Every day already walked keeps the scope it was walked under, no ordinary run revisits it, and nothing turns red — the two verdicts that mark an incomplete day,
capped_daysandunenriched_days, both describe rows the walk knew it missed, and these are rows it never knew existed. Recovering the history is a one-offingest.github.rewalkFrom, and the procedure is Widen a source's scope.
Fine-grained PAT, or a GitHub App installed on the org
-
Repository access: the in-scope repositories, or org-wide.
-
Repository permissions:
- Metadata: Read — mandatory baseline.
- Pull requests: Read — the core permission. Covers the PR list, review outcomes, and additions/deletions/changed-files.
- Issues: Read — required since Prism began answering bug-backlog questions. Without it the pull-request half still works and the issue half ingests nothing.
- Contents: not required. Grant Read only if your instance will not return PR size without it; Prism never reads file contents either way. (Required if you enable the live drill-down — see below.)
-
Organization permissions: Members: Read — required since 0.11.0, and the only org-scoped grant Prism asks for.
ONE call needs it: the identity sweep's
GET /orgs/<org>/members, which lists the logins whose public profile address is then read one at a time (ingest.github.profileEmailSweep, on by default).The other org-scoped call,
GET /orgs/<org>, is a reachability pre-flight and a fine-grained token can still make it with no organization permission at all — that was measured, and this page said so before the sweep existed. It is spelt out again here because an earlier draft of this section quietly folded the pre-flight in with the sweep, which would have had a reader believe the grant was needed for more than it is.Without it the ingest still works and the answers get worse in a way that is hard to see. Pull requests, reviews and issues all mirror normally; what fails is the join from a GitHub login to a person, so any question scoped to a team, a manager's organisation or a named individual resolves to nobody. The sweep reports the refusal in the ingest tick's log and nowhere else.
Set
ingest.github.profileEmailSweep: falseif you would rather not grant it. That is a supported configuration — it trades per-person attribution for one less permission — and turning it off is louder than being refused, because the tick then says on every run that no login will be resolved.
If the optional live drill-down MCP is on (mcps.githubLive.enabled), this
same token serves it — the chart's ingest Jobs and the live MCP both read secret
key github-token, compose gives both GITHUB_TOKEN, and the Cloud Run
deployment mounts one secret into all three consumers. It opens one named pull
request on request and can return that PR's diff, changed files, comments and CI
checks, so add three grants to the list above, all Read: Contents,
Checks, Commit statuses. The last two are what the CI-checks view
needs — they are separate GitHub permissions, and without them that view fails
with Resource not accessible by personal access token while everything else
works. Trimming the list the other way — to what the live MCP alone needs — is
what drops Issues: Read and empties the issue half. One token, one union,
until it can hold its own (prism #572).
Classic PAT
read:org plus repo read — read:org covers both the org-scoped search and,
since 0.11.0, the member listing the identity sweep needs. On a classic token
that one scope carries both, so there is nothing extra to add. On private repositories,
"repo read" is the broad
repo scope, which also grants WRITE. There is no read-only classic scope for
private repos. If your security review requires a genuinely read-only
credential — and for most organisations it does — use a fine-grained token or a
GitHub App instead.
Two things that are easy to miss
- If the org enforces SAML SSO, the token must be SSO-authorized for that org, or it will authenticate and then see nothing.
- A fine-grained token returns no
x-oauth-scopesheader at all, so an empty header is not evidence of a problem. Take a source live has commands that test what the credential can actually reach, which is the check that matters.
6. Where it connects
| Deployment | REST | GraphQL |
|---|---|---|
| github.com / Enterprise Cloud | https://api.github.com | https://api.github.com/graphql |
| Enterprise Server (self-hosted) | https://<your-host>/api/v3 | https://<your-host>/api/graphql |
Prism polls on a schedule; no inbound webhook is required. It needs egress to that endpoint from where it runs — see Egress.
GraphQL is a separate hop, and an Enterprise Server can serve REST while
blocking it. If that happens you get pull requests with no size or review data
rather than an outright failure, so it is worth testing both. The run names the
days it could not enrich, in unenriched_days — see
Go-live.
7. There's a hard rate cap
ingest.github.maxRequestsPerMinute sets a fixed ceiling on calls to
GitHub. Because the sync is already serial, this is a true requests-per-minute
limit — no burst can exceed it, and retries and back-offs are counted against
it too, so a job being throttled upstream cannot speed up.
# values.yaml
ingest:
github:
maxRequestsPerMinute: 30 # the shipped default; 0 disables the cap
The default of 30/min sits at GitHub's tightest documented budget for the search endpoint the walk uses. Set it lower if your platform team asks you to — it costs the first backfill time, not data. Limits has the detail.
8. What happens when a backfill is interrupted
Relevant if you have set a low rate cap, because a slow backfill is a long one.
Progress is kept. The job records how far it got after each completed day of history, and a later run picks up from there — it does not start again and it does not re-truncate what it already fetched. A pod killed mid-run, an evicted node, or a run that hits its deadline all resume from the last completed day.
Two consequences worth planning for:
- The first live run empties the table before it refills it, so that a deployment moving off seeded demo data does not serve a mix of the two. Until the backfill completes, Prism answers from a partial mirror and says so — coverage, staleness and "these figures are a floor" are reported with the answer.
- Do not change
ingest.github.orgswhile the first backfill is running. Progress is only trusted for the orgs that produced it, so adding one correctly discards it — and starts the backfill over, truncation included.
If you want an interrupted backfill to resume sooner, shorten the schedule.
Do not shorten ingest.runDeadlineSeconds. A resumed run re-walks the last
day it completed before moving on — deliberately, because a day finished at
14:00 does not yet hold what was updated later that day — so each run needs long
enough for two busy days of history, not one. Below that the job restarts
for ever and the backfill never finishes.
The shipped default (24 hours) has hours of headroom at any sane rate cap, so this is a warning about lowering it rather than a reason to raise it. If a backfill is not completing, the tell is that the day it resumes from stops changing between runs — the row count and the logs otherwise look like ordinary interruptions.
9. Issue types, and the one upgrade that needs a re-sweep
From this release the issue mirror also holds GitHub's issue type — the
org-level classification (Task, Bug, Feature, or whatever your org
configured) that sits beside labels rather than among them.
It matters because a question about bugs means both axes at once. An
organisation that turned issue types on typically stopped applying the bug
label, and one that never turned them on still uses it, so where an
organisation uses both, counting either axis alone undercounts the backlog —
on the repository this was found in, the labelled issues were a minority of the
union. Where an organisation never enabled types, the label axis alone is the
whole backlog and nothing is missing; gh api orgs/<org>/issue-types tells you
which case you are in, and is worth running before you act on any of this. Prism answers a bug question as
the union of the two and reports which axis found each issue, so an answer
reads "N open bugs: X typed, Y labelled, Z both" rather than a bare number.
Answering across both axes needs the semantic query MCP, which ships on
since 0.10.0. The union, and the "which axis found it" breakdown, are served
only by that component, so if your values file pins
mcps.semanticMcp.enabled: false (see docs/install/parameters.md) bug
questions keep being answered on the label alone, however full the type column
is. The re-sweep below fills the column
durably either way — it is stored, not computed at question time — but nothing
you gain from it is visible until the flag is on.
This one field is not recoverable from what is already stored. Every issue already in the mirror carries an empty type until the lane fetches it again, and an empty type is indistinguishable from a repository that has none — so until the re-sweep below has been all the way round, the typed half of a bug figure is missing or short. Nothing in the answer says so: a bug figure taken part way through the sweep reads exactly like a complete one, so treat the typed half as partial until you know the sweep has finished.
To fill it in, re-walk the issue history once with the standard re-sweep lever
("Widening the window later" in docs/install/go-live.md has the full procedure):
# values.yaml
ingest:
github:
rewalkFrom: "2026-03-01" # a date at or before the issue horizon
Two things to get right:
-
Know what your date buys. A date at the mirror's issue horizon re-fetches everything already mirrored and fills in its types — the complete type re-sweep. A date earlier than the horizon does more, not nothing: it widens the mirror itself, pulling in issues last touched back then that were never mirrored at all, at the same per-day request cost — that is the "Widening the window later" procedure in
docs/install/go-live.md, and it is a coverage decision, not part of the type refresh. The issue horizon is recorded separately from the pull-request horizon that Admin → Self test and Admin → Context streams report, and no admin surface shows it today; read it from the mirror directly:kubectl exec -n <namespace> statefulset/<release>-postgres -- \psql -U postgres -d prism -c \"SELECT detail->>'issue_backfill_horizon' FROM ingest_state WHERE source = 'github';" -
A re-sweep costs what the original backfill cost, so give it the same room: check
ingest.github.maxRequestsPerMinuteandingest.runDeadlineSecondsbefore you start, and expect it to take hours at a low cap. Nothing is truncated and no coverage already earned is given up.
The value is carried out once per date — the run that performs the re-sweep records the date, so leaving it in your values file does not re-walk the same months at every firing of the schedule.
If you would rather not spend the requests, nothing breaks — but the figures
do not stand still either. The label axis is the floor, not a resting state:
every ordinary scheduled run writes the type on each issue it touches, so the
typed half fills in gradually as issues see activity, so an un-re-swept bug
figure may drift upward over the following weeks for a bookkeeping reason as
well as a real one — and the delta on its own does not say which. If the
organisation does not use types there is no drift from this cause at all. The answer will not tell you any of this — it
carries no warning about an empty or part-filled type column, so the figure
reads as complete whatever state the column is in. That makes two things yours
to hold, not Prism's: knowing whether this re-sweep has run, and never taking a
typed count of zero as a finding about the repositories — an organisation that
never enabled issue types, one that has them on but leaves bugs to the label,
one whose bug type is named something else (type names are org-defined free
text, and this axis matches the literal bug, so a Defect or Incident type
reads as zero with a fully populated column), and a column the sweep has not
reached yet all produce the same zero, and the column cannot tell you which you
are looking at. gh api orgs/<org>/issue-types is what tells you.
What that means in practice
- Steady state: each run fetches only what changed — a handful of requests.
- Initial backfill: one org over 90 days is on the order of a thousand requests; at the default cap, tens of minutes. At a low cap, plan in hours, and see §8.
- Peak concurrency against GitHub: one request. Not one per user, not one per page in parallel — one, full stop.
You hold the levers: the org scope (ingest.github.orgs), the backfill window
(ingest.backfillDays), the schedule, and the hard cap
(maxRequestsPerMinute). If you want it gentler, turn any of them down.