npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 on

The 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 token returns whatever GH_TOKEN is exported, ignoring the keyring — measured while building this. --user reads 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 of GH_TOKEN and friends as well, so a call site that ever forgot --user fails 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: 4706

A 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_limit is deliberately never called. In the same session it reported 5000/5000 while the response headers on a real call reported 4710. 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 reports pullRequestsSkipped. Nothing is dropped in silence.
  • The test is a non-null pull_request value, 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": null on a genuine issue (#786), and keying on presence alone would silently drop those. One exported isPullRequest(), so nobody writes a second, subtly different check.
  • issues.get() refuses one with PullRequestNotAnIssueError, 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` reports

Evidence 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#123 in 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.com

Nothing 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.

scopes is empty whenever state is not valid, and that means "not known". gh reads them off the X-OAuth-Scopes response header, so there is no list when nothing responded. It is the same trap one field along: check state before 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 | +1THUMBS_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.