diff --git a/.changeset/github-enterprise.md b/.changeset/github-enterprise.md new file mode 100644 index 00000000..13590989 --- /dev/null +++ b/.changeset/github-enterprise.md @@ -0,0 +1,7 @@ +--- +'@stats-forge/github-stats-forge-core': minor +'@stats-forge/github-stats-forge-cli': minor +'@stats-forge/github-stats-forge-server': minor +--- + +feat: add `CardConfig`'s `githubApiUrl`, read from `GITHUB_API_URL`, to draw the cards from a GitHub Enterprise Server diff --git a/apps/docs/src/content/docs/docs/usage/cli.md b/apps/docs/src/content/docs/docs/usage/cli.md index 9337286e..6914f384 100644 --- a/apps/docs/src/content/docs/docs/usage/cli.md +++ b/apps/docs/src/content/docs/docs/usage/cli.md @@ -44,6 +44,9 @@ Every card but WakaTime needs a [GitHub token](../tokens/). The CLI looks for on 4. `GH_TOKEN`, then `GITHUB_TOKEN`, as the GitHub CLI and GitHub Actions set them. 5. A prompt, if it still has none. +On a GitHub Enterprise Server, `GITHUB_API_URL` names its API, `https://HOST/api/v3`, as its +runners already set it. + The `GITHUB_TOKEN` a workflow is given cannot read gists or an organization's members, so the gist and organization cards need a token of your own. diff --git a/apps/docs/src/content/docs/docs/usage/library.md b/apps/docs/src/content/docs/docs/usage/library.md index ba1af933..13035d69 100644 --- a/apps/docs/src/content/docs/docs/usage/library.md +++ b/apps/docs/src/content/docs/docs/usage/library.md @@ -82,6 +82,7 @@ const config = new CardConfig({ requestTimeoutMs: 10_000, hints: { tokens_missing: 'Set PAT_1 in the environment' }, logger: { log: myLog.info, error: myLog.error }, + githubApiUrl: 'https://github.example.com/api/v3', }); ``` @@ -96,9 +97,10 @@ const config = new CardConfig({ | `requestTimeoutMs` | How long one request may wait before it fails as `upstream`; omit to wait | | `hints` | Your own second line for a failure, by `reason`, on the card and in `text` | | `logger` | Where diagnostics go, such as a token hitting its rate limit; `console` by default | +| `githubApiUrl` | A GitHub Enterprise Server's REST API; its GraphQL API is found beside it | `CardConfig.fromEnv(process.env)` builds the same thing from `PAT_1`…, `ALLOWLIST`, -`GIST_ALLOWLIST`, `EXCLUDE_REPO` and `FETCH_MULTI_PAGE_STARS`, for a host that would rather +`GIST_ALLOWLIST`, `EXCLUDE_REPO`, `FETCH_MULTI_PAGE_STARS` and `GITHUB_API_URL`, for a host that would rather configure through the environment. A `PAT_` variable set to nothing is skipped. The library's own advice assumes nothing about where a token comes from. A host that knows, such diff --git a/apps/docs/src/content/docs/docs/usage/self-hosting.md b/apps/docs/src/content/docs/docs/usage/self-hosting.md index 10eb5002..5355f2f8 100644 --- a/apps/docs/src/content/docs/docs/usage/self-hosting.md +++ b/apps/docs/src/content/docs/docs/usage/self-hosting.md @@ -83,6 +83,7 @@ Everything but `PAT_1` is optional. | `CACHE_UPSTREAM_MB` | `16` | memory for GitHub's and WakaTime's answers; `0` holds none | | `CACHE_DIR` | — | where held cards are kept across a restart | | `CORS_ORIGIN` | — | the one origin allowed to read a card from script | +| `GITHUB_API_URL` | GitHub.com's | a GitHub Enterprise Server's API, `https://HOST/api/v3` | See [GitHub tokens](../tokens/) for which token each card needs. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f7f3d3b1..638c096a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -286,6 +286,8 @@ const main = async (): Promise => { pats: tokens, requestTimeoutMs: REQUEST_TIMEOUT_MS, logger: stderrLogger, + // set by an Enterprise Server's runner, as GH_TOKEN and GITHUB_TOKEN are + githubApiUrl: process.env['GITHUB_API_URL'] || undefined, hints: { rate_limited: "Every token has hit GitHub's rate limit; try again later, or pass another with --pat", diff --git a/packages/cli/tests/index.spec.ts b/packages/cli/tests/index.spec.ts index 0f198c51..430005cd 100644 --- a/packages/cli/tests/index.spec.ts +++ b/packages/cli/tests/index.spec.ts @@ -115,9 +115,9 @@ beforeEach(async () => { await writeSavedCard(savedGist, gist, { id: 'abc', theme: 'ember_dark' }); ({ argv } = process); - // a token in the shell running the suite must not reach a test that expects none + // a token or API in the shell running the suite must not reach a test that expects none for (const name of Object.keys(process.env).filter((key) => - /^(?:PAT_\d+|GH_TOKEN|GITHUB_TOKEN)$/u.test(key), + /^(?:PAT_\d+|GH_TOKEN|GITHUB_TOKEN|GITHUB_API_URL)$/u.test(key), )) { vi.stubEnv(name, undefined); } @@ -229,6 +229,15 @@ describe('the command line', () => { expect(prompts.navigateOptions).not.toHaveBeenCalled(); }); + it('asks the API GITHUB_API_URL names, as an Enterprise Server runner sets it', async () => { + vi.stubEnv('GITHUB_API_URL', 'https://ghe.example.com/api/v3'); + render.mockResolvedValue(DRAWN); + + await run('--config', savedGist, '--generate', '--pat', 'ghp_x', '--out', join(dir, 'a.svg')); + + expect(render.mock.calls[0]?.[1].githubApiUrl).toBe('https://ghe.example.com/api/v3'); + }); + it("creates --out's folders when they are not there yet", async () => { render.mockResolvedValue(DRAWN); const out = join(dir, 'cards', 'dark', 'card.svg'); diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index b955d43f..541ae6b5 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -118,6 +118,8 @@ every default to the renderer (a card's default width, which only the card knows both; the members row only when `show` names it. Log the refusal once, not per page. - **Commits are REST only, against a smaller allowance**: the activity card's commit row is behind `show`, and a refused search leaves it `null`. +- **No fetcher writes GitHub's host**: a REST URL starts at the `apiUrl` the retryer hands it, and + `createGraphQLFetcher` derives GraphQL's from the same, so `githubApiUrl` reaches every request. - **The generator is dev-only**, typechecked by `packages/core/tsconfig.scripts.json`. - **`graphql` is 17, and GitHub's schema is built with `assumeValid: true`**: its SDL breaks a v17 deprecation rule that `assumeValidSDL` alone doesn't skip. The queries are still validated. diff --git a/packages/core/src/common/config.ts b/packages/core/src/common/config.ts index 5c798479..c56b15df 100644 --- a/packages/core/src/common/config.ts +++ b/packages/core/src/common/config.ts @@ -1,5 +1,5 @@ import type { FailureReason } from './error.ts'; -import { defaultFetch, withTimeout } from './http.ts'; +import { GITHUB_API_URL, defaultFetch, withTimeout } from './http.ts'; import type { FetchLike } from './http.ts'; import { consoleLogger } from './log.ts'; import type { Logger } from './log.ts'; @@ -27,6 +27,7 @@ interface CardConfigInit { requestTimeoutMs?: number | undefined; hints?: Partial>; logger?: Logger; + githubApiUrl?: string | undefined; } /** @@ -84,6 +85,8 @@ export class CardConfig { readonly hints: Partial>; /** Where core's diagnostics go; defaults to `console`. */ readonly logger: Logger; + /** The REST API's base, a GitHub Enterprise Server's `https://HOST/api/v3`; no trailing slash. */ + readonly githubApiUrl: string; /** The transport as given, so `with` never wraps it twice. */ private readonly transport: FetchLike; @@ -98,6 +101,7 @@ export class CardConfig { this.fetch = withTimeout(this.transport, this.requestTimeoutMs); this.hints = init.hints ?? {}; this.logger = init.logger ?? consoleLogger; + this.githubApiUrl = (init.githubApiUrl ?? GITHUB_API_URL).replace(/\/+$/, ''); } /** @returns Config for this deployment. */ @@ -108,6 +112,8 @@ export class CardConfig { gistAllowlist: parseCsv(env['GIST_ALLOWLIST']), excludeRepositories: parseCsv(env['EXCLUDE_REPO']) ?? [], fetchMultiPageStars: parseFetchMultiPageStars(env['FETCH_MULTI_PAGE_STARS']), + // the name GitHub Actions sets, an Enterprise Server's runner included + githubApiUrl: env['GITHUB_API_URL'] || undefined, }); } @@ -134,6 +140,7 @@ export class CardConfig { requestTimeoutMs: this.requestTimeoutMs, hints: this.hints, logger: this.logger, + githubApiUrl: this.githubApiUrl, ...overrides, }); } diff --git a/packages/core/src/common/http.ts b/packages/core/src/common/http.ts index b8a3dbef..4a37a796 100644 --- a/packages/core/src/common/http.ts +++ b/packages/core/src/common/http.ts @@ -1,6 +1,15 @@ import type { GraphQLDocument } from '../graphql/graphqlDocument.ts'; -const GITHUB_GRAPHQL_API = 'https://api.github.com/graphql'; +/** GitHub.com's REST API, which a GitHub Enterprise Server host replaces with its own `/api/v3`. */ +const GITHUB_API_URL = 'https://api.github.com'; + +/** + * Where the GraphQL API sits beside a REST base, as Octokit derives it: GitHub.com keeps it beside + * the REST API, and an Enterprise Server moves it from `/api/v3` to `/api/graphql`. + * + * @returns The GraphQL endpoint. + */ +const graphqlUrl = (apiUrl: string): string => `${apiUrl.replace(/\/api\/v3$/, '/api')}/graphql`; /** * The slice of `fetch` this library calls. @@ -41,6 +50,8 @@ interface HttpResponse { interface FetcherContext { /** Transport to send the request with. */ fetch: FetchLike; + /** The REST API's base, with no trailing slash: `https://api.github.com` unless the host says. */ + apiUrl: string; /** Attempt index, from 0. Tests read it to fake a rate limit on the first token. */ retries: number; } @@ -113,11 +124,11 @@ const createGraphQLFetcher = async ( variables: TVariables, token: string, - { fetch }: FetcherContext, + { fetch, apiUrl }: FetcherContext, ): Promise> => { const response = await httpRequest['data']>( fetch, - GITHUB_GRAPHQL_API, + graphqlUrl(apiUrl), { method: 'POST', headers: { @@ -130,5 +141,12 @@ const createGraphQLFetcher = return { ...response, data: asEnvelope(response.data) as GraphQLResponse['data'] }; }; -export { createGraphQLFetcher, defaultFetch, httpRequest, UNREADABLE_REPLY, withTimeout }; +export { + createGraphQLFetcher, + defaultFetch, + GITHUB_API_URL, + httpRequest, + UNREADABLE_REPLY, + withTimeout, +}; export type { FetcherContext, FetchLike, GraphQLResponse, HttpResponse }; diff --git a/packages/core/src/common/retryer.ts b/packages/core/src/common/retryer.ts index 0c7ceffb..c6eac9e7 100644 --- a/packages/core/src/common/retryer.ts +++ b/packages/core/src/common/retryer.ts @@ -91,6 +91,7 @@ const retryer = async >( // a non-2xx comes back as a response, so only a transport failure throws response = await fetcher(variables, currentPAT.value, { fetch: config.fetch, + apiUrl: config.githubApiUrl, retries: attempt, }); } catch (error) { diff --git a/packages/core/src/fetchers/org-activity/index.ts b/packages/core/src/fetchers/org-activity/index.ts index 3cf6de4f..9eb3c5eb 100644 --- a/packages/core/src/fetchers/org-activity/index.ts +++ b/packages/core/src/fetchers/org-activity/index.ts @@ -81,10 +81,10 @@ const issuesRefused = (...searches: Array> => { const query = `org:${org}+author-date:${toSearchDate(range.from)}..${toSearchDate(range.to)}`; - return httpRequest(fetch, `https://api.github.com/search/commits?per_page=1&q=${query}`, { + return httpRequest(fetch, `${apiUrl}/search/commits?per_page=1&q=${query}`, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/packages/core/src/fetchers/stats/index.ts b/packages/core/src/fetchers/stats/index.ts index 7c5c9b6d..a1fd7c75 100644 --- a/packages/core/src/fetchers/stats/index.ts +++ b/packages/core/src/fetchers/stats/index.ts @@ -149,11 +149,11 @@ interface SearchVariables { const fetchTotalItems = async ( { type, filter, scope }: SearchVariables, token: string, - { fetch }: FetcherContext, + { fetch, apiUrl }: FetcherContext, ): Promise> => httpRequest( fetch, - `https://api.github.com/search/${type}?per_page=1&q=${buildSearchFilter(scope).replaceAll( + `${apiUrl}/search/${type}?per_page=1&q=${buildSearchFilter(scope).replaceAll( ' ', '+', )}${filter}`, diff --git a/packages/core/src/fetchers/top-languages/jupyter.ts b/packages/core/src/fetchers/top-languages/jupyter.ts index 18854a8a..d1949296 100644 --- a/packages/core/src/fetchers/top-languages/jupyter.ts +++ b/packages/core/src/fetchers/top-languages/jupyter.ts @@ -215,9 +215,9 @@ const headers = (token: string, accept: string): HeadersInit => ({ const fetchTree = async ( { nameWithOwner }: { nameWithOwner: string }, token: string, - { fetch }: FetcherContext, + { fetch, apiUrl }: FetcherContext, ): Promise> => - httpRequest(fetch, `https://api.github.com/repos/${nameWithOwner}/git/trees/HEAD?recursive=1`, { + httpRequest(fetch, `${apiUrl}/repos/${nameWithOwner}/git/trees/HEAD?recursive=1`, { headers: headers(token, 'application/vnd.github+json'), }); @@ -225,9 +225,9 @@ const fetchTree = async ( const fetchBlob = async ( { nameWithOwner, sha }: { nameWithOwner: string; sha: string }, token: string, - { fetch }: FetcherContext, + { fetch, apiUrl }: FetcherContext, ): Promise => - httpRequest(fetch, `https://api.github.com/repos/${nameWithOwner}/git/blobs/${sha}`, { + httpRequest(fetch, `${apiUrl}/repos/${nameWithOwner}/git/blobs/${sha}`, { headers: headers(token, 'application/vnd.github.raw+json'), }); diff --git a/packages/core/tests/config.spec.ts b/packages/core/tests/config.spec.ts index aa2784c9..4db5b20e 100644 --- a/packages/core/tests/config.spec.ts +++ b/packages/core/tests/config.spec.ts @@ -122,3 +122,17 @@ describe('CardConfig.logger', () => { expect(config.with({ logger: testLogger }).logger).toBe(testLogger); }); }); + +describe('CardConfig.githubApiUrl', () => { + it("is GitHub.com's unless the host names another", () => { + expect(new CardConfig().githubApiUrl).toBe('https://api.github.com'); + expect(CardConfig.fromEnv({ GITHUB_API_URL: '' }).githubApiUrl).toBe('https://api.github.com'); + }); + + it('reads the GITHUB_API_URL an Enterprise Server runner sets, without its trailing slash', () => { + const config = CardConfig.fromEnv({ GITHUB_API_URL: 'https://ghe.example.com/api/v3/' }); + + expect(config.githubApiUrl).toBe('https://ghe.example.com/api/v3'); + expect(config.with({ pats: [] }).githubApiUrl).toBe('https://ghe.example.com/api/v3'); + }); +}); diff --git a/packages/core/tests/fetchStats.spec.ts b/packages/core/tests/fetchStats.spec.ts index 64adce0b..5e0dd08e 100644 --- a/packages/core/tests/fetchStats.spec.ts +++ b/packages/core/tests/fetchStats.spec.ts @@ -264,28 +264,28 @@ const expectedStats = ( ...overrides, }); +/** @returns The answer for whichever of the card's queries was sent. */ +const answerStatsQuery = (cfg: Parameters[0]): [number, unknown] => { + const req = graphqlBody(cfg); + + if (String(req.variables['startTime']).startsWith('2003')) { + return [200, data_year2003]; + } + if (req.query.includes('userReposContributedTo')) { + return [200, data_repos_contributed_to]; + } + if (req.query.includes('contributionCalendar')) { + return [200, data_contributions]; + } + if (req.query.includes('totalCommitContributions')) { + return [200, req.variables['includeUserRepositories'] ? data_stats_with_own_repos : data_stats]; + } + return [200, data_repo]; +}; + beforeEach(() => { config = baseConfig; // The default fetches only one page of stars. - mock.onPost(GRAPHQL).reply((cfg) => { - const req = graphqlBody(cfg); - - if (String(req.variables['startTime']).startsWith('2003')) { - return [200, data_year2003]; - } - if (req.query.includes('userReposContributedTo')) { - return [200, data_repos_contributed_to]; - } - if (req.query.includes('contributionCalendar')) { - return [200, data_contributions]; - } - if (req.query.includes('totalCommitContributions')) { - return [ - 200, - req.variables['includeUserRepositories'] ? data_stats_with_own_repos : data_stats, - ]; - } - return [200, data_repo]; - }); + mock.onPost(GRAPHQL).reply(answerStatsQuery); }); afterEach(() => { @@ -330,6 +330,20 @@ describe('Test fetchStats', () => { ); }); + it('asks a GitHub Enterprise Server for everything, its GraphQL API and its REST API alike', async () => { + config = baseConfig.with({ githubApiUrl: 'https://ghe.example.com/api/v3/' }); + mock.onPost('https://ghe.example.com/api/graphql').reply(answerStatsQuery); + mock + .onGet('https://ghe.example.com/api/v3/search/commits?per_page=1&q=author:anuraghazra') + .reply(200, commits_1000); + + const stats = await fetchStatsWith({ include_all_commits: true }); + + expect(stats.totalCommits).toBe(1000); + const hosts = [...mock.history.post, ...mock.history.get].map(({ url }) => new URL(url).host); + expect(new Set(hosts)).toStrictEqual(new Set(['ghe.example.com'])); + }); + it('should throw specific error when include_all_commits true and invalid username', async () => { await expect( fetchStats({ username: 'asdf///---', include_all_commits: true }, config), diff --git a/packages/core/tests/retryer.spec.ts b/packages/core/tests/retryer.spec.ts index dac0ed07..d725ea16 100644 --- a/packages/core/tests/retryer.spec.ts +++ b/packages/core/tests/retryer.spec.ts @@ -183,9 +183,10 @@ describe(retryer, () => { expect(tokens[2]).toBe(tokens[1]); }); - it('uses the PATs the config carries', async () => { + it('uses the PATs and the API the config carries', async () => { const userConfig = testConfig.with({ pats: [{ name: 'user PAT from database', value: 'user-pat-token' }], + githubApiUrl: 'https://ghe.example.com/api/v3', }); const fetcher = vi.fn(async (_variables, token) => Promise.resolve(answer({ token }))); @@ -194,6 +195,7 @@ describe(retryer, () => { ); expect(fetcher).toHaveBeenCalledExactlyOnceWith({}, 'user-pat-token', { fetch: userConfig.fetch, + apiUrl: 'https://ghe.example.com/api/v3', retries: 0, }); });