diff --git a/.changeset/resilient-gitmap-contributions.md b/.changeset/resilient-gitmap-contributions.md new file mode 100644 index 0000000..2cb70ef --- /dev/null +++ b/.changeset/resilient-gitmap-contributions.md @@ -0,0 +1,9 @@ +--- +'@getdashfy/ext-github': patch +--- + +Make the `contributions` endpoint (used by the `Gitmap` widget) more resilient to failures from the public [github-contributions-api](https://github.com/grubersjoe/github-contributions-api) instance: + +- Request the last year first (`?y=last`), matching what `Gitmap` renders, and fall back to full history if that request fails. +- Treat an `{ "error": "..." }` response body as a failure and retry, in addition to non-2xx responses. +- Add a `contributionsApiBaseUrl` client option to point at a self-hosted `github-contributions-api` instance. diff --git a/README.md b/README.md index 6def38a..13ca668 100755 --- a/README.md +++ b/README.md @@ -176,6 +176,11 @@ createGitHubClient({ // Request timeout in milliseconds timeout: 10_000, // default + + // Base URL of a github-contributions-api instance, used by the + // `contributions` endpoint (the Gitmap widget). Point this at a + // self-hosted deployment if the public instance is unavailable. + contributionsApiBaseUrl: 'https://github-contributions-api.jogruber.de/v4', // default }) ``` @@ -551,7 +556,13 @@ GitHub API has rate limits that vary based on authentication: ### Contribution heatmap not loading -**Solution:** The Gitmap widget uses a third-party API ([github-contributions-api](https://github.com/grubersjoe/github-contributions-api)) which may have its own rate limits. +**Solution:** The Gitmap widget uses a third-party API ([github-contributions-api](https://github.com/grubersjoe/github-contributions-api)) which may have its own rate limits, or may occasionally fail to scrape GitHub (e.g. an `"other side closed"` error). The client first requests the last year of data, then retries with full history before giving up. If the public instance stays unavailable, [self-host it](https://github.com/grubersjoe/github-contributions-api) and point the extension at your instance: + +```ts +createGitHubClient({ + contributionsApiBaseUrl: 'https://your-host/v4', +}) +``` ## Contributing diff --git a/src/client.test.ts b/src/client.test.ts index 296e025..23da4b7 100755 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -362,7 +362,7 @@ describe('createGitHubClient', () => { }) describe('contributions endpoint', () => { - it('should fetch user contributions', async () => { + it('should fetch the last year of contributions by default', async () => { const mockContributions = { total: { '2024': 365, '2023': 200 }, contributions: [ @@ -377,9 +377,10 @@ describe('createGitHubClient', () => { const result = await api.contributions!({ user: 'octocat' }) + expect(mockRequest).toHaveBeenCalledOnce() expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ - url: 'https://github-contributions-api.jogruber.de/v4/octocat', + url: 'https://github-contributions-api.jogruber.de/v4/octocat?y=last', method: 'GET', headers: expect.objectContaining({ 'User-Agent': '@getdashfy/ext-github', @@ -404,7 +405,7 @@ describe('createGitHubClient', () => { await api.contributions!({ user: 'testuser' }) expect(mockLogger.info).toHaveBeenCalledWith( - '[github.contributions] Fetching https://github-contributions-api.jogruber.de/v4/testuser', + '[github.contributions] Fetching https://github-contributions-api.jogruber.de/v4/testuser?y=last', ) }) @@ -426,6 +427,88 @@ describe('createGitHubClient', () => { ) }) + it('should use a custom contributionsApiBaseUrl', async () => { + const mockContributions = { total: {}, contributions: [] } + const mockRequest = vi.fn().mockResolvedValue(mockContributions) + const client = createGitHubClient({ + contributionsApiBaseUrl: 'http://localhost:3000/v4', + }) + const api = client({ logger: mockLogger, request: mockRequest }) + + await api.contributions!({ user: 'octocat' }) + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:3000/v4/octocat?y=last', + }), + ) + }) + + it('should fall back to full history when the last-year request fails', async () => { + const fullHistoryContributions = { + total: { '2024': 365, '2023': 200 }, + contributions: [{ date: '2024-01-01', count: 5, level: 4 }], + } + const mockRequest = vi + .fn() + .mockRejectedValueOnce(new Error('HTTP 500: other side closed')) + .mockResolvedValueOnce(fullHistoryContributions) + const client = createGitHubClient({}) + const api = client({ logger: mockLogger, request: mockRequest }) + + const result = await api.contributions!({ user: 'octocat' }) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(mockRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + url: 'https://github-contributions-api.jogruber.de/v4/octocat?y=last', + }), + ) + expect(mockRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: 'https://github-contributions-api.jogruber.de/v4/octocat', + }), + ) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch last year'), + ) + expect(result).toEqual(fullHistoryContributions) + }) + + it('should fall back to full history when the API returns an error body', async () => { + const fullHistoryContributions = { + total: { '2024': 365 }, + contributions: [{ date: '2024-01-01', count: 1, level: 1 }], + } + const mockRequest = vi + .fn() + .mockResolvedValueOnce({ error: 'other side closed' }) + .mockResolvedValueOnce(fullHistoryContributions) + const client = createGitHubClient({}) + const api = client({ logger: mockLogger, request: mockRequest }) + + const result = await api.contributions!({ user: 'octocat' }) + + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(result).toEqual(fullHistoryContributions) + }) + + it('should throw a clear error when both attempts fail', async () => { + const mockRequest = vi + .fn() + .mockRejectedValueOnce(new Error('HTTP 500: other side closed')) + .mockRejectedValueOnce(new Error('HTTP 500: other side closed')) + const client = createGitHubClient({}) + const api = client({ logger: mockLogger, request: mockRequest }) + + await expect(api.contributions!({ user: 'octocat' })).rejects.toThrow( + 'GitHub contributions API error: HTTP 500: other side closed', + ) + expect(mockRequest).toHaveBeenCalledTimes(2) + }) + it('should return contributions with correct structure', async () => { const mockContributions = { total: { '2024': 100, '2023': 50 }, diff --git a/src/client.ts b/src/client.ts index ad47402..516dfe8 100755 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,9 @@ const DEFAULT_TIMEOUT = 10_000 const DEFAULT_USER_AGENT = '@getdashfy/ext-github' const DEFAULT_ACCEPT_HEADER = 'application/vnd.github+json' +/** @see https://github.com/grubersjoe/github-contributions-api */ +const DEFAULT_CONTRIBUTIONS_API_BASE_URL = 'https://github-contributions-api.jogruber.de/v4' + export interface GitHubClientConfig { /** * GitHub API base URL @@ -39,6 +42,14 @@ export interface GitHubClientConfig { * @default 10_000 */ timeout?: number + /** + * Base URL of a github-contributions-api instance, used by the `contributions` + * endpoint (the Gitmap widget). Point this at a self-hosted deployment if the + * public instance is unavailable or blocked. + * @default 'https://github-contributions-api.jogruber.de/v4' + * @see https://github.com/grubersjoe/github-contributions-api + */ + contributionsApiBaseUrl?: string } /** @@ -48,6 +59,8 @@ export interface GitHubClientConfig { * @param config.baseUrl - GitHub API base URL (default: 'https://api.github.com') * @param config.token - Personal access token for authentication * @param config.timeout - Request timeout in milliseconds (default: 10_000) + * @param config.contributionsApiBaseUrl - Base URL of a github-contributions-api + * instance, used by `contributions` (the Gitmap widget) * @returns API registration function for Dashfy * * @example @@ -69,7 +82,12 @@ export interface GitHubClientConfig { * @see https://github.com/settings/tokens - Get your GitHub personal access token */ export function createGitHubClient(config: GitHubClientConfig): APIRegistration { - const { baseUrl = DEFAULT_API_BASE_URL, token, timeout = DEFAULT_TIMEOUT } = config + const { + baseUrl = DEFAULT_API_BASE_URL, + token, + timeout = DEFAULT_TIMEOUT, + contributionsApiBaseUrl = DEFAULT_CONTRIBUTIONS_API_BASE_URL, + } = config return ({ logger, request }) => { if (!request) { @@ -124,6 +142,28 @@ export function createGitHubClient(config: GitHubClientConfig): APIRegistration } } + /** + * Fetch a single github-contributions-api URL and validate the response + * shape. The API can return an error body (e.g. `{ "error": "..." }`) + * alongside a non-2xx status, which the underlying request helper + * already rejects on; this also guards against an unexpected 2xx/error + * payload. + */ + const requestContributions = async (url: string): Promise => { + const data = (await request({ + url, + method: 'GET', + headers: { 'User-Agent': DEFAULT_USER_AGENT }, + timeout, + })) as GithubContributions & { error?: string } + + if (data.error || !data.contributions) { + throw new Error(data.error ?? 'unexpected response shape') + } + + return { total: data.total, contributions: data.contributions } + } + return { /** * Get user profile information. @@ -318,28 +358,31 @@ export function createGitHubClient(config: GitHubClientConfig): APIRegistration }, /** - * Get user contributions (GitHub contribution graph data). + * Get user contributions (GitHub contribution graph data). Tries the + * last year first (what Gitmap renders), falling back to full history + * if that request fails. * Uses the github-contributions-api by @grubersjoe. * * @see https://github.com/grubersjoe/github-contributions-api */ contributions: async ({ user }: { user: string }): Promise => { - const url = `https://github-contributions-api.jogruber.de/v4/${user}` - - const response = await request({ - url, - method: 'GET', - headers: { 'User-Agent': DEFAULT_USER_AGENT }, - timeout, - }) - - logger.info(`[github.contributions] Fetching ${url}`) - - const data = response as GithubContributions - - return { - total: data.total, - contributions: data.contributions, + const lastYearUrl = `${contributionsApiBaseUrl}/${user}?y=last` + const fullHistoryUrl = `${contributionsApiBaseUrl}/${user}` + + logger.info(`[github.contributions] Fetching ${lastYearUrl}`) + + try { + return await requestContributions(lastYearUrl) + } catch (error) { + logger.warn( + `[github.contributions] Failed to fetch last year (${getErrorMessage(error)}), retrying with full history: ${fullHistoryUrl}`, + ) + + try { + return await requestContributions(fullHistoryUrl) + } catch (fallbackError) { + throw new Error(`GitHub contributions API error: ${getErrorMessage(fallbackError)}`) + } } }, }