diff --git a/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts b/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts index 120bfe18d..7925ab72d 100644 --- a/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts +++ b/packages/core/src/backends/git-gateway/__tests__/GitHubAPI.spec.ts @@ -12,6 +12,8 @@ const createApi = (options: Partial = {}) => { cmsLabelPrefix: 'CMS', isLargeMedia: () => Promise.resolve(false), commitAuthor: { name: 'Bob' }, + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), ...options, }); }; diff --git a/packages/core/src/backends/git-gateway/implementation.tsx b/packages/core/src/backends/git-gateway/implementation.tsx index 79a445e25..1e61db51c 100644 --- a/packages/core/src/backends/git-gateway/implementation.tsx +++ b/packages/core/src/backends/git-gateway/implementation.tsx @@ -316,8 +316,13 @@ export default class GitGateway implements BackendClass { }; if (this.backendType === 'github') { - this.api = new GitHubAPI(apiConfig); - this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api }); + this.backend = new GitHubBackend(this.config, { ...this.options }); + this.api = new GitHubAPI({ + ...apiConfig, + getUser: this.backend.currentUser, + getRepo: this.backend.getRepo, + }); + this.backend.api = this.api; } else if (this.backendType === 'gitlab') { this.api = new GitLabAPI(apiConfig); this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api }); diff --git a/packages/core/src/backends/github/API.ts b/packages/core/src/backends/github/API.ts index e9b04749a..bd55dd701 100644 --- a/packages/core/src/backends/github/API.ts +++ b/packages/core/src/backends/github/API.ts @@ -76,6 +76,7 @@ export interface Config { apiRoot?: string; token?: string; authScheme?: AuthScheme; + authenticateAsGithubApp?: boolean; branch?: string; useOpenAuthoring?: boolean; openAuthoringEnabled?: boolean; @@ -84,6 +85,8 @@ export interface Config { squashMerges: boolean; initialWorkflowStatus: WorkflowStatus; cmsLabelPrefix: string; + getUser: ({ token }: { token: string }) => Promise; + getRepo: ({ token }: { token: string }) => Promise; } type Override = Pick> & U; @@ -164,6 +167,7 @@ export default class API { apiRoot: string; token: string; authScheme: AuthScheme; + authenticateAsGithubApp: boolean; branch: string; useOpenAuthoring?: boolean; openAuthoringEnabled?: boolean; @@ -180,14 +184,18 @@ export default class API { cmsLabelPrefix: string; _userPromise?: Promise; + _repoPromise?: Promise; _metadataSemaphore?: Semaphore; + getUser: ({ token }: { token: string }) => Promise; + getRepo: ({ token }: { token: string }) => Promise; commitAuthor?: {}; constructor(config: Config) { this.apiRoot = config.apiRoot || 'https://api.github.com'; this.token = config.token || ''; this.authScheme = config.authScheme || 'token'; + this.authenticateAsGithubApp = config.authenticateAsGithubApp || false; this.branch = config.branch || 'main'; this.useOpenAuthoring = config.useOpenAuthoring; this.repo = config.repo || ''; @@ -207,26 +215,30 @@ export default class API { this.cmsLabelPrefix = config.cmsLabelPrefix; this.initialWorkflowStatus = config.initialWorkflowStatus; this.openAuthoringEnabled = config.openAuthoringEnabled; + + this.getUser = config.getUser; + this.getRepo = config.getRepo; } static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS'; - user(): Promise<{ name: string; login: string }> { + user(): Promise<{ name: string; login: string; avatar_url?: string }> { if (!this._userPromise) { - this._userPromise = this.getUser(); + this._userPromise = this.getUser({ token: this.token }); } return this._userPromise; } - getUser() { - return this.request('/user') as Promise; - } - async hasWriteAccess() { try { - const result: ReposGetResponse = await this.request(this.repoURL); + const result: ReposGetResponse = await this.getRepo({ token: this.token }); // update config repoOwner to avoid case sensitivity issues with GitHub this.repoOwner = result.owner.login; + + // Github App tokens won't have permissions set appropriately in the response, so we bypass that check + if (this.authenticateAsGithubApp) { + return true; + } return result.permissions.push; } catch (error) { console.error('Problem fetching repo data from GitHub'); diff --git a/packages/core/src/backends/github/__tests__/API.spec.ts b/packages/core/src/backends/github/__tests__/API.spec.ts index dc658ef4a..485a49499 100644 --- a/packages/core/src/backends/github/__tests__/API.spec.ts +++ b/packages/core/src/backends/github/__tests__/API.spec.ts @@ -31,6 +31,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); api.createTree = jest.fn().mockImplementation(() => Promise.resolve({ sha: 'newTreeSha' })); @@ -83,6 +85,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); fetch.mockResolvedValue({ @@ -112,6 +116,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); fetch.mockResolvedValue({ @@ -140,6 +146,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); fetch.mockResolvedValue({ @@ -167,6 +175,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); api.requestHeaders = jest.fn().mockResolvedValue({ @@ -201,6 +211,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); const responses = { @@ -305,6 +317,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); const tree = [ @@ -391,6 +405,8 @@ describe('github API', () => { squashMerges: false, initialWorkflowStatus: WorkflowStatus.DRAFT, cmsLabelPrefix: '', + getUser: () => Promise.reject('Unexpected call'), + getRepo: () => Promise.reject('Unexpected call'), }); const tree = [ diff --git a/packages/core/src/backends/github/implementation.tsx b/packages/core/src/backends/github/implementation.tsx index fb6eaee28..278352d3e 100644 --- a/packages/core/src/backends/github/implementation.tsx +++ b/packages/core/src/backends/github/implementation.tsx @@ -40,7 +40,7 @@ import type { import type { AsyncLock } from '@staticcms/core/lib/util'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { Semaphore } from 'semaphore'; -import type { GitHubUser } from './types'; +import type { GitHubUser, ReposGetResponse } from './types'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -77,9 +77,11 @@ export default class GitHub implements BackendClass { previewContext: string; token: string | null; authScheme: AuthScheme; + authenticateAsGithubApp: boolean; squashMerges: boolean; cmsLabelPrefix: string; _currentUserPromise?: Promise; + _getRepoPromise?: Promise; _userIsOriginMaintainerPromises?: { [key: string]: Promise; }; @@ -117,6 +119,7 @@ export default class GitHub implements BackendClass { this.apiRoot = config.backend.api_root || 'https://api.github.com'; this.token = ''; this.authScheme = config.backend.auth_scheme || 'token'; + this.authenticateAsGithubApp = config.backend.authenticate_as_github_app || false; this.squashMerges = config.backend.squash_merges || false; this.cmsLabelPrefix = config.backend.cms_label_prefix || ''; this.mediaFolder = config.media_folder; @@ -145,8 +148,7 @@ export default class GitHub implements BackendClass { // no need to check auth if api is down if (api) { auth = - (await this.api - ?.getUser() + (await this.currentUser({ token: this.token || '' }) .then(user => !!user) .catch(e => { console.warn('[StaticCMS] Failed getting GitHub user', e); @@ -193,14 +195,46 @@ export default class GitHub implements BackendClass { return Promise.resolve(); } - async currentUser({ token }: { token: string }) { - if (!this._currentUserPromise) { - this._currentUserPromise = fetch(`${this.apiRoot}/user`, { + async getRepo({ token }: { token: string }) { + if (!this._getRepoPromise) { + this._getRepoPromise = fetch(`${this.apiRoot}/repos/${this.repo}`, { headers: { Authorization: `${this.authScheme} ${token}`, }, }).then(res => res.json()); } + return this._getRepoPromise; + } + + async getApp({ token }: { token: string }) { + return fetch(`${this.apiRoot}/app`, { + headers: { + Authorization: `${this.authScheme} ${token}`, + }, + }) + .then(res => res.json()) + .then( + res => + ({ + name: res.name, + login: res.slug, + avatar_url: `https://avatars.githubusercontent.com/in/${res.id}?v=4`, + }) as GitHubUser, + ); + } + + async currentUser({ token }: { token: string }) { + if (!this._currentUserPromise) { + if (this.authenticateAsGithubApp) { + this._currentUserPromise = this.getApp({ token }); + } else { + this._currentUserPromise = fetch(`${this.apiRoot}/user`, { + headers: { + Authorization: `${this.authScheme} ${token}`, + }, + }).then(res => res.json()); + } + } return this._currentUserPromise; } @@ -293,6 +327,7 @@ export default class GitHub implements BackendClass { this.api = new apiCtor({ token: this.token, authScheme: this.authScheme, + authenticateAsGithubApp: this.authenticateAsGithubApp, branch: this.branch, repo: this.repo, originRepo: this.originRepo, @@ -302,8 +337,10 @@ export default class GitHub implements BackendClass { useOpenAuthoring: this.useOpenAuthoring, openAuthoringEnabled: this.openAuthoringEnabled, initialWorkflowStatus: this.options.initialWorkflowStatus, + getUser: this.currentUser, + getRepo: this.getRepo, }); - const user = await this.api!.user(); + const user = await this.currentUser({ token: this.token }); const isCollab = await this.api!.hasWriteAccess().catch(error => { error.message = stripIndent` Repo "${this.repo}" not found. diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index 47fa3f91c..f3b1dc84f 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -1028,6 +1028,7 @@ export interface Backend { gateway_url?: string; auth_scope?: AuthScope; auth_scheme?: AuthScheme; + authenticate_as_github_app?: boolean; commit_messages?: { create?: string; update?: string;