Skip to content

Commit eb7907a

Browse files
committed
feat: implement GitHub App auth with fallback
1 parent 5b22a49 commit eb7907a

File tree

5 files changed

+186
-5
lines changed

5 files changed

+186
-5
lines changed

__tests__/auth.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
authenticate,
3+
GITHUB_AUTH_API_KEY,
4+
GITHUB_AUTH_SERVICE_URL
5+
} from '../src/auth'
6+
import { jest } from '@jest/globals'
7+
8+
describe('authenticate', () => {
9+
const mockFetch = jest.spyOn(global, 'fetch')
10+
11+
beforeEach(() => {
12+
mockFetch.mockClear()
13+
})
14+
15+
it('should use GitHub App authentication when app is installed', async () => {
16+
mockFetch.mockResolvedValue({
17+
ok: true,
18+
status: 200,
19+
json: async () => Promise.resolve({ token: 'ghs_app_123' })
20+
} as Response)
21+
22+
const result = await authenticate(
23+
{ owner: 'dunder-mifflin', repo: 'website' },
24+
'fallback_token'
25+
)
26+
27+
expect(result).toBe('ghs_app_123')
28+
expect(mockFetch).toHaveBeenCalledWith(
29+
`${GITHUB_AUTH_SERVICE_URL}/github/dunder-mifflin/website/installation-token`,
30+
expect.objectContaining({
31+
method: 'POST',
32+
headers: { Authorization: `Bearer ${GITHUB_AUTH_API_KEY}` }
33+
})
34+
)
35+
})
36+
37+
it('should fall back to standard authentication when app is not installed', async () => {
38+
mockFetch.mockResolvedValue({
39+
ok: false,
40+
status: 404,
41+
json: async () => Promise.resolve({ error: 'Not installed' })
42+
} as Response)
43+
44+
const result = await authenticate(
45+
{ owner: 'dunder-mifflin', repo: 'website' },
46+
'fallback_token'
47+
)
48+
49+
expect(result).toBe('fallback_token')
50+
})
51+
52+
it('should fall back to standard authentication when service is unavailable', async () => {
53+
mockFetch.mockRejectedValue(new Error('Network error'))
54+
55+
const result = await authenticate(
56+
{ owner: 'dunder-mifflin', repo: 'website' },
57+
'fallback_token'
58+
)
59+
60+
expect(result).toBe('fallback_token')
61+
})
62+
})

dist/index.js

Lines changed: 52 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/auth.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as core from '@actions/core'
2+
import type { Context } from '@actions/github/lib/context'
3+
4+
export const GITHUB_AUTH_SERVICE_URL =
5+
'https://github-auth.staging.code-pushup.dev'
6+
7+
export const GITHUB_AUTH_API_KEY =
8+
'18850f2513adad10662e85f4f085a9714e64cef7793fc2ffe903b5ddcd62de42'
9+
10+
type TokenResponse = {
11+
token: string
12+
}
13+
14+
export async function authenticate(
15+
{ owner, repo }: Context['repo'],
16+
fallbackToken: string
17+
): Promise<string> {
18+
try {
19+
const response = await fetch(
20+
`${GITHUB_AUTH_SERVICE_URL}/github/${owner}/${repo}/installation-token`,
21+
{
22+
method: 'POST',
23+
headers: {
24+
Authorization: `Bearer ${GITHUB_AUTH_API_KEY}`
25+
}
26+
}
27+
)
28+
const data = await response.json()
29+
if (response.ok && isTokenResponse(data)) {
30+
core.info('Using Code PushUp GitHub App authentication')
31+
return data.token
32+
}
33+
handleErrorResponse(response.status)
34+
} catch (error) {
35+
core.warning(
36+
`Unable to contact Code PushUp authentication service: ${error}`
37+
)
38+
}
39+
core.info('Using standard token authentication')
40+
return fallbackToken
41+
}
42+
43+
function isTokenResponse(res: unknown): res is TokenResponse {
44+
return (
45+
!!res &&
46+
typeof res === 'object' &&
47+
'token' in res &&
48+
typeof res.token === 'string'
49+
)
50+
}
51+
52+
function handleErrorResponse(status: number): void {
53+
switch (status) {
54+
case 404:
55+
core.debug('Code PushUp GitHub App not installed on this repository')
56+
break
57+
case 401:
58+
core.warning('Code PushUp authentication service authorization failed')
59+
break
60+
case 500:
61+
core.warning('Code PushUp authentication service temporarily unavailable')
62+
break
63+
default:
64+
core.debug(`Code PushUp authentication service returned status ${status}`)
65+
}
66+
}

src/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { simpleGit } from 'simple-git'
66
import { createAnnotationsFromIssues } from './annotations'
77
import { GitHubApiClient } from './api'
88
import { REPORT_ARTIFACT_NAME, uploadArtifact } from './artifact'
9+
import { authenticate } from './auth'
910
import { parseInputs } from './inputs'
1011
import { createOptions } from './options'
1112
import { parseGitRefs } from './refs'
@@ -44,7 +45,10 @@ export async function run(
4445
}
4546

4647
const refs = parseGitRefs()
47-
const api = new GitHubApiClient(inputs.token, refs, artifact, getOctokit)
48+
49+
const token = await authenticate(github.context.repo, inputs.token)
50+
51+
const api = new GitHubApiClient(token, refs, artifact, getOctokit)
4852

4953
const result = await runInCI(refs, api, options, git)
5054

0 commit comments

Comments
 (0)