@floh-solutions/gh-core
v0.4.0
Published
Typed GitHub Issues client for Pharos. Multi-account by construction: a repo is a (repo, account) pair and every call pins its identity from `gh auth token --user`.
Readme
@floh-solutions/gh-core
The GitHub Issues client for Pharos, and the one home for the edge logic that joins an issue to an Azure DevOps work item.
It is a separate package rather than a folder inside ado-core because
ado-core's write guards — the test op on /rev, the revision comparison —
are Azure-DevOps-specific and mean nothing here.
Fixtures and examples use contoso/contoso/widgets. A real organisation and a
real GitHub account reach this through configuration and are never written down
(#20).
The two things this package is built around
Both were measured on 2026-08-08, not recalled. Each removes a design option that a plausible implementation would otherwise take.
1. A repo is a (repo, account) pair
Azure DevOps binds to one GitHub identity. gh is the opposite — it holds
many accounts at once and has a notion of which is active, machine-wide.
gh 2.87.3
✓ github.com account ASNNetworks ← ACTIVE
✓ github.com account alisina-tibata ← owns the repo behind the project we work onThe active account was the wrong one, live, on the machine this was written
on. And the failure is not a clean error: with two accounts in play,
owner/widgets can exist under both, so the wrong identity returns 200 for a
different repository. Silent, and wrong.
So the account travels with the repo everywhere, and there is no API in this package that takes a repo without one:
await client.issues.get({ repo: "contoso/widgets", account: "alisina-tibata", number: 45 });| what | how |
|---|---|
| credential | gh auth token --user <account>, then Authorization: Bearer over plain HTTPS |
| the binding | ~/.config/pharos/repos.json, written by pharos setup, read by the CLI and the app |
| an unbound repo | an error naming the repo and the file — never a fallback to the active account |
| gh auth switch | refused by construction, see ALLOWED_GH_COMMANDS |
gh auth switch mutates machine-global state that the user's own terminal,
their editor and every other agent session depend on. A background sweep that
switched accounts would change what the human's next hand-typed gh command
does. Per-invocation pinning has none of that blast radius.
A bare
gh auth tokenreturns whateverGH_TOKENis exported, ignoring the keyring — measured while building this.--userreads the keyring and ignores the variable, so it is not merely how an account is selected: it is what stops an exported token in somebody's shell from silently becoming the identity of a sweep. The child environment is scrubbed ofGH_TOKENand friends as well, so a call site that ever forgot--userfails loudly rather than inheriting.
2. A 304 costs nothing
GET …/issues → 200, x-ratelimit-remaining: 4709
GET …/issues → 200, x-ratelimit-remaining: 4707
GET …/issues If-None-Match → 304, x-ratelimit-remaining: 4707
GET …/issues If-None-Match → 304, x-ratelimit-remaining: 4707
GET …/issues If-None-Match → 304, x-ratelimit-remaining: 4707
GET …/issues → 200, x-ratelimit-remaining: 4706A 200 costs one of 5,000/hour; three consecutive 304s cost nothing. That is what makes an idle repository nearly free to poll, and it is why the watermark poll survives beside the webhook nudge (non-negotiable #2, applied to a second source).
This is why gh api is not the transport. gh api exits non-zero on a
304 — the success case of a cheap poll is an error to it — and surfaces
x-ratelimit-* only as scraped text under --include. gh is the credential
source; the call is an ordinary HTTPS request. That also makes this package and
the Swift port two implementations of the same wire behaviour, which is the
only thing that gives the shared marker fixture any meaning.
GET /rate_limitis deliberately never called. In the same session it reported5000/5000while the response headers on a real call reported4710. The number that governs the request you are about to make is the one that came back on the request you just made; a separate probe is a second answer, and the second answer is the one nobody is looking at.
Pull requests are issues, and that is a trap
GET /repos/{owner}/{name}/issues returns pull requests. Every PR is an
issue on that endpoint, distinguished only by a pull_request key on the
object. Measured against cli/cli: both items of a per_page=2 page were PRs.
PRs are out of scope for v1, so an unfiltered sweep silently mirrors every PR in
the repository as an issue, and pharos issue trail then resolves numbers that
are not issues. Two of two implementations hit this independently — this one and
the Swift read sweep on #783 — so the filter is at the client boundary, not
in each caller:
issues.list()drops them and reportspullRequestsSkipped. Nothing is dropped in silence.- The test is a non-null
pull_requestvalue, not merely the key. On the REST list endpoint the key is absent or an object and never null — but the hub found the webhook payload spelling it"pull_request": nullon a genuine issue (#786), and keying on presence alone would silently drop those. One exportedisPullRequest(), so nobody writes a second, subtly different check. issues.get()refuses one withPullRequestNotAnIssueError, which is distinct from "no such issue":/issues/{n}resolves a PR number too, so guarding only the bulk read leaves the other door open.
Pagination follows the Link header verbatim and never computes the next
page. The issues endpoint paginates by cursor:
link: <https://api.github.com/repositories/212613049/issues?…&after=Y3Vyc29yOnYyOpLPAAAB…&page=2>; rel="next"A different path and a cursor. Rebuilding ?page=2 by hand drops the cursor,
and GitHub answers it — with the wrong window. That is the quietest way to lose
issues in a backfill.
The link marker is a spec, not an implementation detail
<!-- pharos:v1 repo=contoso/widgets issue=45 ado=4821 -->It lives in a Pharos comment, never in the issue body: editing a reporter's body collides with them, is rude on a public repo, and needs write access we may not have on a community issue. A comment is append-only and needs none of that.
There are two implementations of this grammar — TypeScript here and Swift in
PharosCore — and two parsers agreeing with their own author is worth nothing.
So test/fixtures/marker-fixtures.json is the contract and both sides run
it. Agreed on #project/github-bridge; do not move that file without saying so
there.
Three rules in it are decisions rather than descriptions:
| | |
|---|---|
| repo is lower-cased on write and on parse | GitHub is case-insensitive for lookup and case-preserving in responses, so one repository has several spellings. Lower-casing on write means there is exactly one byte-string per link and an exact compare is correct, not merely usual. |
| two trailers: the first wins | Refusing would let one malformed comment hide a real link — including from the echo guard, which would then let the fan-out loop. |
| the echo guard is laxer than the parser | pharos:v2 does not parse as a v1 link but does count as ours. A false positive costs one comment not mirrored; a false negative is an unbounded loop across two platforms. Hence two functions: findMarker (strict) and isPharosAuthored (any version). |
The gh account never appears in the trailer. It is local configuration, it names a person's login, and that string is published into a comment anybody can read.
Why the guard is content and not the actor
GitHub events carry sender.login, which looks like it makes the loop problem
easy and does the opposite. Because every client writes with its own gh
credential, sender.login == me is also true of the human typing into the web
UI by hand — an actor check would swallow their real comments. The guard is the
marker plus the updated_at watermark.
Using it
import { GitHubClient, composeLinkComment, issueUrl } from "@floh-solutions/gh-core";
const client = await GitHubClient.fromConfig(); // reads repos.json
const binding = client.binding("contoso/widgets"); // throws if unbound
// The watermark sweep. A quiet repo answers 304 and costs nothing.
const sweep = await client.issues.list(binding, { since: watermark, etag });
if (!sweep.notModified) {
save(sweep.issues, sweep.watermark); // the MAX updated_at, not the last row
}
// The edge: one Pharos comment carrying all three layers of the link record.
await client.issues.comment(
{ ...binding, number: 45 },
composeLinkComment({ repo: binding.repo, issue: 45, adoId: 4821, workItemUrl }),
);
// …and the ADO end is a Hyperlink relation to:
issueUrl({ repo: binding.repo, number: 45 });issueUrl is the ADO end because _apis/githubconnections is 401 on
Pharos's PAT scopes (a control WIQL call is 200 with the same token), so the
connection id needed to mint a native vstfs:///GitHub/Issue/… artifact link
cannot be read. A plain Hyperlink needs no admin grant and no scope change.
client.edits — the issue's own fields, and the guard GitHub refuses
const ref = { ...binding, number: 45 };
// Guarded. The base is what your editor opened with; the call re-reads and
// refuses if THAT text moved.
const { issue, changed, guard } = await client.edits.fields(ref, {
title: "Crash on resize",
base: { title: "Widget falls over on resize" },
});
// changed: ["title"] guard: { kind: "value", compared: ["title"], atomic: false }
await client.edits.labels(ref, { add: ["bug"], remove: ["needs-triage"] });
const { ignored } = await client.edits.assignees(ref, { add: ["some-login"] });
// `ignored` is not decoration — see below.There is no conditional write on this endpoint. PATCH
/repos/{o}/{r}/issues/{n} with If-Match answers 400 Bad Request
("Conditional request headers are not allowed in unsafe requests unless
supported by the endpoint") — measured against a real issue, twice,
independently. A correct etag and a garbage etag give byte-identical answers, so
freshness is never evaluated, and a control PATCH with no header is 200. GraphQL
is not a second door: UpdateIssueInput has no precondition field. Never send
If-Match defensively — it is not inert, it fails the write, and the 400 reads
like a malformed body.
Nor can the guard be a timestamp. updated_at and the ETag both move when
somebody merely comments, so refusing on either refuses a legitimate edit for a
change that touched no field — it fires on the case that is not a collision.
That is AGENTS.md non-negotiable #3 arriving on GitHub with the ETag playing
System.Rev.
So {@link IssueEditApi.fields} compares the value being overwritten: re-read,
compare, send, adjacent, with nothing in between. compareIssueBase is the pure
decision and is tested directly. It is not atomic and nothing client-side can
make it so; the refusal says that rather than implying a guarantee that is not
there. IssueEditConflictError carries the remote issue and, for a rename, the
renamed timeline event naming who did it.
Two measured silences these endpoints have, both handled here rather than by callers:
| endpoint | what it does quietly |
|---|---|
| POST …/labels | creates a repository label that does not exist — 200, colour ededed, permanent, spelled exactly as sent. Unknown names are refused with near misses; createMissing is the explicit act |
| POST …/assignees | ignores a login it will not assign and still answers 201. AssigneeEditResult.ignored is the diff against what was asked |
And their removals disagree: removing a label that is not on the issue is 404, removing an assignee who is not assigned is 200. Both are reported rather than thrown — a removal whose reply was lost and is retried got what it asked for.
Labels and assignees never ride the whole-issue PATCH. It carries them as whole arrays, which would make one label write a blind overwrite of every label; their own sub-resources put them back inside plan §7's append argument, where there is no previous value to guard.
client.comments — everything you can do to one after it exists
const ref = { ...binding, number: 45 };
// The state and permissions REST cannot report. One GraphQL call per 100.
const { comments } = await client.comments.details(ref);
comments[0].viewerCanUpdate; // may I edit it — before trying and getting a 403
comments[0].id; // the REST id: edit, delete, reactions
comments[0].nodeId; // the node id: hide, pin. Neither derives from the other
await client.comments.edit(ref, 900, "New words."); // guarded — see below
await client.comments.remove(ref, 900); // a 404 is success
await client.comments.setReaction(ref, 900, parseReaction("+1")!, true);
await client.comments.setHidden(binding, nodeId, parseHideReason("off-topic"));
await client.comments.setPinned(binding, nodeId, true);issues.comments() is untouched by any of this. It is the REST list on the
conditional-request budget and it is what linksFromComments reads; the GraphQL
read is an addition beside it, which is the same split the macOS app made.
The doors are chosen by what GitHub offers, not by taste. POST …/minimize
answers 404 — there is no REST route for hiding, none at all for pinning,
and no REST field for isMinimized, viewerCan… or "have I reacted". Those
five go GraphQL. Everything expressible in REST stays REST, on the transport
that already knows a 403 from a rate limit and what a 401 does to a cached
credential.
A GraphQL failure is an HTTP 200. A malformed selection, a bad enum member
or a node id naming nothing all answer 200 OK with {"errors":[…]} and no
data. GitHubHttp checks status codes — correctly, for REST — so every
GraphQL call goes through GitHubGraphQL.query, which reads errors before it
looks at data and throws GitHubGraphQLError. That error is deliberately
not a GitHubError subclass: everything derived from that carries a real
status, and putting status: 200 on it invites the status >= 400 check that
would drop it on the floor.
Two field names measured rather than guessed, because both fail as a cheerful
200: minimizeComment takes subjectId and pinIssueComment takes
issueCommentId, though they sit beside each other in GitHub's schema. And
a classifier round-trips asymmetrically — send OFF_TOPIC, read back
off-topic.
The marker guard — guardEditedBody
An edit must neither double nor strip the pharos:v1 trailer. That
rule was written down twice before this package and enforced nowhere: the Swift
path preserves the trailer by round-tripping the stored body, which is a
property of its caller rather than a guarantee of the call. CommentsApi.edit
is the only way to reach the PATCH, and it reads the comment first so the guard
cannot be skipped by passing the wrong thing.
| comment now | proposed body | outcome |
|---|---|---|
| any | two or more trailers | refused — doubled |
| no trailer | one trailer | refused — forged |
| a trailer | a different trailer | refused — repointed |
| a trailer | no trailer | restored, and markerPreserved says so |
| a trailer | the same trailer | sent unchanged — the round trip |
It guards raw trailer text, not parsed markers, and that is the whole
subtlety. findMarker returns only usable v1 markers, while isPharosAuthored
— the echo guard — answers true for any trailer at all, including a malformed
one and a pharos:v2 from a newer build. Guarding on parsed markers would let
an edit strip exactly the trailer whose loss starts a fan-out loop across two
platforms.
repos.json
{
"version": 1,
"repos": [
{ "repo": "contoso/widgets", "account": "alisina-tibata" },
{ "repo": "floh/internal", "account": "ASNNetworks" }
]
}Found at $PHAROS_REPOS_FILE, else $XDG_CONFIG_HOME/pharos/repos.json, else
~/.config/pharos/repos.json. Unknown keys are ignored, so a newer pharos
setup cannot break an older reader. Two accounts for one repo is refused:
picking one would be an invisible choice.
Rebuilding the link — github_link is a cache, never the truth
import { linksFromComments, linksFromHyperlinks, mergeLinks, isTwoSided } from "@floh-solutions/gh-core";
const links = mergeLinks(
linksFromHyperlinks(4821, relationUrlsOnTheWorkItem), // the ADO end
linksFromComments({ repo, number: 45 }, comments), // the GitHub end
);
links.filter((link) => !isTwoSided(link)); // what `issue drift` reportsEvidence is kept rather than collapsed, because "linked on both platforms" and
"linked on one" are different states and telling them apart is the whole job of
pharos issue drift. Only hyperlink means the ADO end survived and the GitHub
comment did not — which is what adopt leaves behind if it dies halfway.
Two refusals worth knowing, both about inventing a link that is not there:
- A bare
AB#123in somebody else's comment is ignored. It is ordinary text a human might type for their own reasons. A mention counts only in a comment that carries a Pharos trailer anyway — a trailer we cannot parse still proves authorship. - A trailer naming a different issue is ignored. Comment bodies get copy-pasted between issues, and a stale trailer must not attach this issue to somebody else's work item.
verifyAccess — the detector behind two doctor checks
const report = await client.repos.verifyAccess(binding);
// { ok, account, viewer, repository, problem, detail }One implementation for pharos doctor, pharos setup and the app's
Command-line wizard step — the same rule pharos-convert and "is LibreOffice
installed" already follow. It never throws: every state it reports is a
decoded fact, because CommandLineStep.swift never shows a terminal.
problem is one of no-token, unauthorized, not-visible,
wrong-repository, issues-disabled, archived, unknown. The interesting
one is wrong-repository — a 200 from a different repository of the same
name, which is the failure a status code cannot show you.
accounts() — and why "signed out" is a three-way answer
for (const account of await tokens.accounts()) {
// { login, host, active, scopes, state, stateDetail }
}gh auth status is a network call. It does not read the keyring and stop —
it validates every token it holds against the host, and its prose reports a host
it could not reach as a credential that is no good. Measured on gh 2.87.3 with
github.com behind a dead proxy:
X Failed to log in to github.com account ASNNetworks (keyring)
- The token in keyring is invalid.
- To re-authenticate, run: gh auth login -h github.comNothing there is true except the X. The token is fine; the network is not.
This package used to parse that prose, so accounts() came back empty on a
machine holding two accounts whenever the connection was down, and a doctor
reading it told somebody on a plane to run gh auth login — the one thing that
cannot work there (#793).
So it asks for JSON instead. gh auth status --json hosts always exits zero
regardless of auth issues, and keeps an account listed when validation fails,
under a per-account state:
| state | what happened | what to tell the person |
|---|---|---|
| valid | GitHub answered, and accepted the token | nothing |
| rejected | GitHub answered 401/403 — the credential really is bad | gh auth login --user <login> |
| unverified | nobody judged it: host unreachable, GitHub erring, or a gh too old to say | check the network, not the credential |
Same split as GitHubAuthError vs GitHubNetworkError on the HTTP side, for
the same reason. rejected is claimed only when GitHub itself refused; a
5xx, a DNS failure and a dead proxy are all unverified, because each leaves
the credential unjudged.
scopesis empty wheneverstateis notvalid, and that means "not known".ghreads them off theX-OAuth-Scopesresponse header, so there is no list when nothing responded. It is the same trap one field along: checkstatebefore concluding an account lost a scope.
An older gh writes unknown flag: --json to stderr and nothing to stdout, so
unparseable stdout falls back to the prose parser — which now also reads the
Failed to log in to … line, as unverified. Prose prints the same sentence
for a refused credential and an unreachable host, so that path never says
rejected: it errs towards "check the network", the direction that does no
harm. The fallback is learned once per GhTokenSource, because both commands
are network round trips.
An empty list now means what it says: gh knows of no accounts. It is no longer
also how "the network is down" arrives.
Tests
pnpm --filter @floh-solutions/gh-core test # 253, hermetic: no network, no gh
GH_LIVE=1 pnpm --filter @floh-solutions/gh-core test # + 7 read-only live checks
GH_LIVE_WRITE=1 pnpm --filter @floh-solutions/gh-core test # + 12 live WRITES. See below.Every offline suite injects its own fetch and its own command runner, so
nothing spawns gh and nothing depends on which accounts this machine holds.
The single exception spawns a two-line shell script the test writes itself, to
cover the one decision a double would replace rather than exercise: the default
runner rejecting an aborted process instead of resolving it as an empty answer.
The live suite is opt-in and read-only, and it exists for one reason: a double
can only prove the code does what its author believed GitHub does. It re-runs
the measurements the design rests on — the identity pin, the untouched active
account, the free 304, the PR leak, and both halves of the state split above —
so the day GitHub or gh changes one, a test says so instead of a sweep quietly
costing 100× its budget. GH_LIVE_REPO defaults to a large public repository
and no client's data is touched.
Two of those need a real gh rather than a real GitHub: unverified is checked
by pointing the child environment's HTTPS_PROXY at a dead port, which
reproduces #793 against the binary that produced it. Nothing on the machine is
reconfigured.
The live write suite — GH_LIVE_WRITE, and never GH_LIVE
test/live-write.test.ts is the only thing in this package that changes
anybody's data, and it is opt-in twice over.
It has its own flag. GH_LIVE=1 does not enable it. That separation is
#823: the Swift write suite gated on the read flag, so the exact invocation
the read suite's own docstring teaches — minus the --filter — opened an issue
on a real repository. The gate is a pure function of the environment and the
separation is asserted hermetically on every offline run, because a suite
wrongly enabled by the read flag looks green; it just writes to somebody's
repository on the way.
And the repository comes from repos.json. No public-repo default: it takes
the first binding and skips when the file binds nothing. GH_LIVE_WRITE_REPO
may name a bound repo; an unbound one is an error, never a guess. A run that
was asked for and then silently skipped everything is itself a failure, so one
test checks for that and is never skipped.
It opens one issue, reuses it, deletes every comment it writes, and closes
the issue as not planned in afterAll — so a red assertion cannot leave
litter on somebody's repository.
The nine comment verbs had been proven once, by hand, by driving the CLI against a throwaway issue (#827). That evidence was a transcript: worth something the day it was taken and worth nothing the day GitHub renames a field. What is asserted now is the set of claims where GitHub's answer, not this package's code, is the risk:
| claim | what rests on it |
|---|---|
| a classifier round-trips asymmetrically — send OFF_TOPIC, read off-topic, for all seven | the wire/display split. Seven rows, one of which had ever been measured |
| every reactionGroups[].content maps back through GITHUB_REACTIONS | +1 ↔ THUMBS_UP. Two spellings, not derivable from each other, hand-written |
| a repeated react does not double the count; un-reacting twice is changed: false | setReaction being a desired end state rather than a toggle — what makes a retry safe |
| a pin survives a re-read | setPinned reports the mutation's own payload; nothing had asked whether the read agrees |
| an edit round-trips the body byte-identically through three doors — PATCH echo, REST GET, GraphQL body | the echo guard reads the body GitHub hands back, and only the first door had been checked |
| a DELETE on a comment that is gone is success, and the GET really is a 404 | the 404-is-success rule, and every retry of a lost delete |
| a 200 carrying errors is a failure | the existence of GitHubGraphQL — measured as a literal status code, not merely as a throw |
That last one is worth its own line. A refused mutation answers
{"data":{"minimizeComment":null},"errors":[{"type":"NOT_FOUND",…}]} — HTTP
200, with data and errors together. A door that took the data because
the data was there would report a hide that never happened as a hide that did.
What the first live run found
Twelve claims held exactly as written. One did not, and it was a claim in the
source rather than in the code. GitHubCommentDetail.lastEditedAt documented
itself by saying a reaction moves updatedAt. Measured through both doors,
on a real comment:
| verb | updatedAt | lastEditedAt |
|---|---|---|
| react / unreact | unchanged | undefined |
| pin / unpin | unchanged | undefined |
| hide / unhide | moves | undefined |
| edit | moves | set, to the same instant |
So the rule — never test for an edit with createdAt !== updatedAt — stands,
and its counterexample is hiding. That is worse than the reaction it
replaces, because hiding is a verb this package ships: the comparison would
have let pharos issue comment hide mark its own target as edited. The doc is
corrected and the table is now asserted.
