Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/resilient-gitmap-contributions.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
```

Expand Down Expand Up @@ -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

Expand Down
89 changes: 86 additions & 3 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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',
Expand All @@ -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',
)
})

Expand All @@ -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 },
Expand Down
79 changes: 61 additions & 18 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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<GithubContributions> => {
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.
Expand Down Expand Up @@ -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<GithubContributions> => {
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)}`)
}
}
},
}
Expand Down
Loading