Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/github-enterprise.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/docs/src/content/docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/src/content/docs/docs/usage/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
```

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/content/docs/docs/usage/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ const main = async (): Promise<void> => {
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",
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/common/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -27,6 +27,7 @@ interface CardConfigInit {
requestTimeoutMs?: number | undefined;
hints?: Partial<Record<FailureReason, string>>;
logger?: Logger;
githubApiUrl?: string | undefined;
}

/**
Expand Down Expand Up @@ -84,6 +85,8 @@ export class CardConfig {
readonly hints: Partial<Record<FailureReason, string>>;
/** 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;

Expand All @@ -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. */
Expand All @@ -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,
});
}

Expand All @@ -134,6 +140,7 @@ export class CardConfig {
requestTimeoutMs: this.requestTimeoutMs,
hints: this.hints,
logger: this.logger,
githubApiUrl: this.githubApiUrl,
...overrides,
});
}
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/common/http.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -41,6 +50,8 @@ interface HttpResponse<TData> {
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;
}
Expand Down Expand Up @@ -113,11 +124,11 @@ const createGraphQLFetcher =
async (
variables: TVariables,
token: string,
{ fetch }: FetcherContext,
{ fetch, apiUrl }: FetcherContext,
): Promise<GraphQLResponse<TResult>> => {
const response = await httpRequest<GraphQLResponse<TResult>['data']>(
fetch,
GITHUB_GRAPHQL_API,
graphqlUrl(apiUrl),
{
method: 'POST',
headers: {
Expand All @@ -130,5 +141,12 @@ const createGraphQLFetcher =
return { ...response, data: asEnvelope(response.data) as GraphQLResponse<TResult>['data'] };
};

export { createGraphQLFetcher, defaultFetch, httpRequest, UNREADABLE_REPLY, withTimeout };
export {
createGraphQLFetcher,
defaultFetch,
GITHUB_API_URL,
httpRequest,
UNREADABLE_REPLY,
withTimeout,
};
export type { FetcherContext, FetchLike, GraphQLResponse, HttpResponse };
1 change: 1 addition & 0 deletions packages/core/src/common/retryer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const retryer = async <TData = unknown, TVariables = Record<string, unknown>>(
// 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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/fetchers/org-activity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ const issuesRefused = (...searches: Array<GetOrganizationActivityQuery['issuesOp
const fetchCommitCount = async (
{ org, range }: { org: string; range: GitHubDateRange },
token: string,
{ fetch }: FetcherContext,
{ fetch, apiUrl }: FetcherContext,
): Promise<HttpResponse<{ total_count?: number }>> => {
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',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/fetchers/stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ interface SearchVariables {
const fetchTotalItems = async (
{ type, filter, scope }: SearchVariables,
token: string,
{ fetch }: FetcherContext,
{ fetch, apiUrl }: FetcherContext,
): Promise<FetcherResponse<{ total_count?: number }>> =>
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}`,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/fetchers/top-languages/jupyter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,19 +215,19 @@ const headers = (token: string, accept: string): HeadersInit => ({
const fetchTree = async (
{ nameWithOwner }: { nameWithOwner: string },
token: string,
{ fetch }: FetcherContext,
{ fetch, apiUrl }: FetcherContext,
): Promise<FetcherResponse<GitTree>> =>
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'),
});

/** The raw media type answers with the notebook itself, which is JSON, rather than base64. */
const fetchBlob = async (
{ nameWithOwner, sha }: { nameWithOwner: string; sha: string },
token: string,
{ fetch }: FetcherContext,
{ fetch, apiUrl }: FetcherContext,
): Promise<FetcherResponse> =>
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'),
});

Expand Down
14 changes: 14 additions & 0 deletions packages/core/tests/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
54 changes: 34 additions & 20 deletions packages/core/tests/fetchStats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,28 +264,28 @@ const expectedStats = (
...overrides,
});

/** @returns The answer for whichever of the card's queries was sent. */
const answerStatsQuery = (cfg: Parameters<typeof graphqlBody>[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(() => {
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/tests/retryer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fetcher>(async (_variables, token) => Promise.resolve(answer({ token })));

Expand All @@ -194,6 +195,7 @@ describe(retryer, () => {
);
expect(fetcher).toHaveBeenCalledExactlyOnceWith({}, 'user-pat-token', {
fetch: userConfig.fetch,
apiUrl: 'https://ghe.example.com/api/v3',
retries: 0,
});
});
Expand Down
Loading