From 44719694de63626453b4caf32f3a122ad2bcbefb Mon Sep 17 00:00:00 2001 From: Jeff Wilcox <427913+jeffwilcox@users.noreply.github.com> Date: Thu, 2 Nov 2023 21:11:55 -0700 Subject: [PATCH] Organization: get Copilot seat assignments Retrieves the list of Copilot basics when the organization has Copilot for Business attachments. Do note that this API only works to page, slowly, at scale to a point - after 400 pages I am not able to get results due to a hard cap of sorts here, so need to rely on other methods to get the info. --- business/githubApps/index.ts | 9 ++ business/organization.ts | 14 +++ business/organizationCopilot.ts | 68 +++++++++++++ lib/github/collections.ts | 167 ++++++++++++++++++++++++-------- lib/github/restApi.ts | 46 ++++++++- transitional.ts | 6 +- 6 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 business/organizationCopilot.ts diff --git a/business/githubApps/index.ts b/business/githubApps/index.ts index fed0d3a1b..09b3f82ab 100644 --- a/business/githubApps/index.ts +++ b/business/githubApps/index.ts @@ -107,6 +107,15 @@ export function getAppPurposeId(purpose: AppPurposeTypes) { return id; } +export function tryGetAppPurposeAppConfiguration(purpose: AppPurposeTypes, organizationName: string) { + if ( + (purpose as ICustomAppPurpose).isCustomAppPurpose === true && + (purpose as ICustomAppPurpose).getForOrganizationName + ) { + return (purpose as ICustomAppPurpose).getForOrganizationName(organizationName); + } +} + export class GitHubAppPurposes { private static _instance: GitHubAppPurposes = new GitHubAppPurposes(); diff --git a/business/organization.ts b/business/organization.ts index 8097a3fb3..6c7d0c715 100644 --- a/business/organization.ts +++ b/business/organization.ts @@ -67,6 +67,7 @@ import { ConfigGitHubTemplates } from '../config/github.templates.types'; import { GitHubTokenManager } from './githubApps/tokenManager'; import { OrganizationProjects } from './projects'; import { OrganizationDomains } from './domains'; +import { OrganizationCopilot } from './organizationCopilot'; interface IGetMembersParameters { org: string; @@ -219,6 +220,8 @@ export class Organization { private _projects: OrganizationProjects; private _domains: OrganizationDomains; + private _copilot: OrganizationCopilot; + id: number; uncontrolled: boolean; @@ -351,6 +354,17 @@ export class Organization { return this._projects; } + get copilot() { + if (!this._copilot) { + this._copilot = new OrganizationCopilot( + this, + this._getSpecificAuthorizationHeader.bind(this), + this._operations + ); + } + return this._copilot; + } + get domains() { if (!this._domains) { this._domains = new OrganizationDomains( diff --git a/business/organizationCopilot.ts b/business/organizationCopilot.ts new file mode 100644 index 000000000..2c9a4a6c0 --- /dev/null +++ b/business/organizationCopilot.ts @@ -0,0 +1,68 @@ +// +// Copyright (c) Microsoft. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +import { + IGetAuthorizationHeader, + IOperationsInstance, + IPagedCacheOptions, + IPurposefulGetAuthorizationHeader, + throwIfNotGitHubCapable, +} from '../interfaces'; +import type { CollectionCopilotSeatsOptions } from '../lib/github/collections'; +import { AppPurpose, AppPurposeTypes } from './githubApps'; +import { CacheDefault, getMaxAgeSeconds, getPageSize } from './operations/core'; +import { Organization } from './organization'; + +export type CopilotSeatData = { + assignee: { + avatar_url: string; + id: number; + login: string; + }; + created_at: string; + updated_at: string; + last_activity_at: string; + last_activity_editor: string; +}; + +export class OrganizationCopilot { + constructor( + private organization: Organization, + private getSpecificAuthorizationHeader: IPurposefulGetAuthorizationHeader, + private operations: IOperationsInstance + ) {} + + async getSeatActivity( + options?: IPagedCacheOptions, + appPurpose: AppPurposeTypes = AppPurpose.Data + ): Promise { + options = options || {}; + const operations = throwIfNotGitHubCapable(this.operations); + const getAuthorizationHeader = this.getSpecificAuthorizationHeader.bind( + this, + appPurpose + ) as IGetAuthorizationHeader; + const github = operations.github; + const parameters: CollectionCopilotSeatsOptions = { + org: this.organization.name, + per_page: getPageSize(operations), + }; + const caching = { + maxAgeSeconds: getMaxAgeSeconds(operations, CacheDefault.orgMembersStaleSeconds, options), + backgroundRefresh: true, + pageRequestDelay: options.pageRequestDelay, + }; + if (options && options.backgroundRefresh === false) { + caching.backgroundRefresh = false; + } + (caching as any).pageLimit = 10; + const seats = (await github.collections.getOrganizationCopilotSeats( + getAuthorizationHeader, + parameters, + caching + )) as CopilotSeatData[]; + return seats; + } +} diff --git a/lib/github/collections.ts b/lib/github/collections.ts index 7f69f1229..b490d3876 100644 --- a/lib/github/collections.ts +++ b/lib/github/collections.ts @@ -13,12 +13,7 @@ import { IRestResponse, flattenData } from './core'; import { CompositeApiContext, CompositeIntelligentEngine } from './composite'; import { Collaborator } from '../../business/collaborator'; import { Team } from '../../business/team'; -import { - IPagedCacheOptions, - IGetAuthorizationHeader, - IDictionary, - GitHubRepositoryPermission, -} from '../../interfaces'; +import { IPagedCacheOptions, IGetAuthorizationHeader, IDictionary } from '../../interfaces'; import { RestLibrary } from '.'; import { sleep } from '../../utils'; import GitHubApplication from '../../business/application'; @@ -29,6 +24,13 @@ export interface IGetAppInstallationsParameters { app_id: string; } +type WithPage = T & { page?: number }; + +export type CollectionCopilotSeatsOptions = { + org: string; + per_page?: number; +}; + export enum GitHubPullRequestState { Open = 'open', Closed = 'closed', @@ -57,6 +59,8 @@ export interface IListPullsParameters { direction?: GitHubSortDirection; } +const mostBasicAccountProperties = ['id', 'login', 'avatar_url']; + const branchDetailsToCopy = ['name', 'commit', 'protected']; const repoDetailsToCopy = RepositoryPrimaryProperties; const teamDetailsToCopy = Team.PrimaryProperties; @@ -65,6 +69,21 @@ const appInstallDetailsToCopy = GitHubApplication.PrimaryInstallationProperties; const contributorsDetailsToCopy = [...Collaborator.PrimaryProperties, 'contributions']; const repoInviteDetailsToCopy = RepositoryInvitation.PrimaryProperties; +type SubReducerProperties = Record; + +type WithSubPropertyReducer = any[] & { subPropertiesToReduce?: SubReducerProperties }; + +const copilotSeatPropertiesToCopy: WithSubPropertyReducer = [ + 'created_at', + 'updated_at', + 'last_activity_at', + 'last_activity_editor', + 'assignee', // id, login; mostBasicAccountProperties +]; +copilotSeatPropertiesToCopy.subPropertiesToReduce = { + assignee: mostBasicAccountProperties, +}; + const teamPermissionsToCopy = [ 'id', 'name', @@ -75,6 +94,7 @@ const teamPermissionsToCopy = [ 'privacy', 'permission', ]; + const teamRepoPermissionsToCopy = [ 'id', 'name', @@ -84,6 +104,7 @@ const teamRepoPermissionsToCopy = [ 'fork', 'permissions', ]; + const pullDetailsToCopy = [ 'id', 'number', @@ -205,6 +226,31 @@ export class RestCollections { ); } + getOrganizationCopilotSeats( + token: string | IGetAuthorizationHeader, + options: CollectionCopilotSeatsOptions, + cacheOptions: IPagedCacheOptions + ): Promise { + // technically type CopilotSeatData + const orgName = options.org; + delete options.org; + const params = Object.assign( + { + octokitRequest: `GET /orgs/${orgName}/copilot/billing/seats`, + }, + options + ); + return this.generalizedCollectionWithFilter( + 'orgCopilotSeats', + 'request', + copilotSeatPropertiesToCopy, + token, + params, + cacheOptions, + 'seats' + ); + } + getAppInstallations( token: string | IGetAuthorizationHeader, parameters: IGetAppInstallationsParameters, @@ -376,11 +422,12 @@ export class RestCollections { ); } - private async getGithubCollection( + private async getGithubCollection( token: string | IGetAuthorizationHeader, - methodName, - options, - cacheOptions: IPagedCacheOptions + methodName: string, + options: OptionsType, + cacheOptions: IPagedCacheOptions, + arrayReducePropertyName?: string ): Promise { const hasNextPage = this.libraryContext.hasNextPage; const githubCall = this.githubCall; @@ -390,14 +437,14 @@ export class RestCollections { const requests = []; let pages = 0; let currentPage = 0; - const pageLimit = options.pageLimit || cacheOptions['pageLimit'] || Number.MAX_VALUE; + const pageLimit = (options as any)?.pageLimit || cacheOptions['pageLimit'] || Number.MAX_VALUE; const pageRequestDelay = cacheOptions.pageRequestDelay || null; while (!done) { const method = githubCall; const args = []; const currentToken = typeof token === 'string' ? token : await token(); args.push(currentToken); - const clonedOptions = Object.assign({}, options); + const clonedOptions: WithPage = Object.assign({}, options); if (++currentPage > 1) { clonedOptions.page = currentPage; } @@ -406,6 +453,19 @@ export class RestCollections { let result = null; try { result = await (method as any).apply(null, args); + if ( + arrayReducePropertyName && + result[arrayReducePropertyName] && + Array.isArray(result[arrayReducePropertyName]) + ) { + const originalResultProperties = { + headers: result?.headers, + cost: result?.cost, + }; + result = result[arrayReducePropertyName]; + result.headers = originalResultProperties.headers; + result.cost = originalResultProperties.cost; + } recentResult = result; if (result) { ++pages; @@ -454,17 +514,26 @@ export class RestCollections { return { data, requests }; } - private async getFilteredGithubCollection( + private async getFilteredGithubCollection( token: string | IGetAuthorizationHeader, - methodName, - options, + methodName: string, + options: OptionsType, cacheOptions: IPagedCacheOptions, - propertiesToKeep + propertiesToKeep: string[], + arrayReducePropertyName?: string ): Promise { const keepAll = !propertiesToKeep; + const subReductionProperties = + propertiesToKeep && (propertiesToKeep as WithSubPropertyReducer).subPropertiesToReduce; try { // IRequestWithData - const getCollectionResponse = await this.getGithubCollection(token, methodName, options, cacheOptions); + const getCollectionResponse = await this.getGithubCollection( + token, + methodName, + options, + cacheOptions, + arrayReducePropertyName + ); if (!getCollectionResponse) { throw new Error('No response'); } @@ -484,6 +553,14 @@ export class RestCollections { const r = {}; _.forOwn(doNotModify, (value, key) => { if (keepAll || propertiesToKeep.indexOf(key) >= 0) { + if (subReductionProperties && subReductionProperties[key]) { + const validSubKeys = new Set(subReductionProperties[key]); + for (const subKey of Object.getOwnPropertyNames(value)) { + if (!validSubKeys.has(subKey)) { + delete value[subKey]; + } + } + } r[key] = value; } }); @@ -502,19 +579,21 @@ export class RestCollections { } } - private async getFilteredGithubCollectionWithMetadataAnalysis( + private async getFilteredGithubCollectionWithMetadataAnalysis( token: string | IGetAuthorizationHeader, - methodName, - options, + methodName: string, + options: OptionsType, cacheOptions: IPagedCacheOptions, - propertiesToKeep + propertiesToKeep: string[], + arrayReducePropertyName?: string ): Promise { - const collectionResults = await this.getFilteredGithubCollection( + const collectionResults = await this.getFilteredGithubCollection( token, methodName, options, cacheOptions, - propertiesToKeep + propertiesToKeep, + arrayReducePropertyName ); const results = collectionResults.data as IRestResponse; const requests = collectionResults.requests; @@ -570,37 +649,47 @@ export class RestCollections { return compositeEngine.execute(apiContext); } - private getCollectionAndFilter( + private getCollectionAndFilter( token: string | IGetAuthorizationHeader, - options, + options: OptionsType, cacheOptions: IPagedCacheOptions, - githubClientMethod, - propertiesToKeep + githubClientMethod: string, + propertiesToKeep: string[], + arrayReducePropertyName?: string ) { const capturedThis = this; - return function (token, options) { - return capturedThis.getFilteredGithubCollectionWithMetadataAnalysis( + return function (token: string | IGetAuthorizationHeader, options: OptionsType) { + return capturedThis.getFilteredGithubCollectionWithMetadataAnalysis( token, githubClientMethod, options, cacheOptions, - propertiesToKeep + propertiesToKeep, + arrayReducePropertyName ); }; } - private async generalizedCollectionWithFilter( - name, - githubClientMethod, - propertiesToKeep, - token, - options, - cacheOptions: IPagedCacheOptions - ): Promise { + private async generalizedCollectionWithFilter( + name: string, + githubClientMethod: string, + propertiesToKeep: string[], + token: string | IGetAuthorizationHeader, + options: OptionsType, + cacheOptions: IPagedCacheOptions, + arrayReducePropertyName?: string + ): Promise { const rows = await this.generalizedCollectionMethod( token, name, - this.getCollectionAndFilter(token, options, cacheOptions, githubClientMethod, propertiesToKeep), + this.getCollectionAndFilter( + token, + options, + cacheOptions, + githubClientMethod, + propertiesToKeep, + arrayReducePropertyName + ), options, cacheOptions ); diff --git a/lib/github/restApi.ts b/lib/github/restApi.ts index 7687f2bed..90434ca5b 100644 --- a/lib/github/restApi.ts +++ b/lib/github/restApi.ts @@ -26,7 +26,14 @@ import { import { getEntityDefinitions, GitHubResponseType, ResponseBodyType } from './endpointEntities'; import appPackage from '../../package.json'; -import { IGetAuthorizationHeader, IAuthorizationHeaderValue } from '../../interfaces'; +import { ErrorHelper } from '../../transitional'; + +import type { IGetAuthorizationHeader, IAuthorizationHeaderValue } from '../../interfaces'; +import { + type IGitHubAppConfiguration, + getAppPurposeId, + tryGetAppPurposeAppConfiguration, +} from '../../business/githubApps'; const appVersion = appPackage.version; @@ -101,10 +108,11 @@ export class IntelligentGitHubEngine extends IntelligentEngine { } } } + const purpose = apiContext?.tokenSource?.purpose ? getAppPurposeId(apiContext.tokenSource.purpose) : null; if (optionalMessage) { let apiTypeSuffix = apiContext.tokenSource && apiContext.tokenSource.purpose - ? ' [' + apiContext.tokenSource.purpose + ']' + ? ' [' + (purpose || apiContext.tokenSource.purpose) + ']' : ''; if (!apiTypeSuffix && apiContext.tokenSource && apiContext.tokenSource.source) { apiTypeSuffix = ` [token source=${apiContext.tokenSource.source}]`; @@ -139,8 +147,38 @@ export class IntelligentGitHubEngine extends IntelligentEngine { args.push(argOptions); } const thisArgument = apiMethod.thisInstance || null; - const response = await apiMethod.apply(thisArgument, args); - return response; + try { + const response = await apiMethod.apply(thisArgument, args); + return response; + } catch (error) { + const asAny = error as any; + if ( + ErrorHelper.IsNotAuthorized(error) && + asAny?.message === 'Resource not accessible by integration' && + apiContext.tokenSource + ) { + let appConfig: IGitHubAppConfiguration = null; + if (apiContext?.tokenSource?.purpose && apiContext?.tokenSource?.organizationName) { + appConfig = tryGetAppPurposeAppConfiguration( + apiContext.tokenSource.purpose, + apiContext.tokenSource.organizationName + ); + } + asAny.source = apiContext.tokenSource.source; + const additional: string[] = []; + purpose && additional.push(`purpose=${purpose}`); + appConfig?.appId && additional.push(`appId=${appConfig.appId}`); + appConfig?.slug && additional.push(`slug=${appConfig.slug}`); + apiContext?.tokenSource?.installationId && + additional.push(`installationId=${apiContext.tokenSource.installationId}`); + apiContext?.tokenSource?.organizationName && + additional.push(`organization=${apiContext.tokenSource.organizationName}`); + const extra = ' ' + additional.join(', '); + debug(`Additional installation context added to message for 403: ${extra}`); + asAny.message += extra; + } + throw error; + } } processMetadataBeforeCall(apiContext: ApiContext, metadata: IRestMetadata) { diff --git a/transitional.ts b/transitional.ts index e69b6f228..6aacad5c1 100644 --- a/transitional.ts +++ b/transitional.ts @@ -254,9 +254,6 @@ export class ErrorHelper { if (asAny?.statusCode && typeof asAny.statusCode === 'number') { return asAny.statusCode as number; } - if (asAny?.code && typeof asAny.code === 'number') { - return asAny.code as number; - } if (asAny?.status) { const status = asAny.status; const type = typeof status; @@ -269,6 +266,9 @@ export class ErrorHelper { return null; } } + if (asAny?.code && typeof asAny.code === 'number') { + return asAny.code as number; + } return null; } }