diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index 145c19a..c822aae 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -18,6 +18,11 @@ import { ok } from 'assert'; import { readFileSync } from 'fs'; import { BuildkiteBuildRequest } from '../src/buildkite'; import nock from 'nock'; +import { + commentsReq, + CommentsRequestData, + mutationReq, +} from '../src/events/commented'; // Enable this to record HTTP requests when adding a new test // nock.recorder.rec(); @@ -629,6 +634,59 @@ describe('github-control', () => { ) .reply(200, updateCommentReply); + const response: CommentsRequestData = { + self: { login: 'xx' }, + comments: { + pullRequest: { + comments: { + nodes: [ + { id: 'A', viewerDidAuthor: true, isMinimized: true }, // minimized already, leave it alone + { id: 'B', viewerDidAuthor: true, isMinimized: false }, + { + id: 'C', + viewerDidAuthor: false, + isMinimized: false, + editor: { + login: 'xx', + }, + }, + { + id: 'D', + viewerDidAuthor: false, + isMinimized: false, + editor: { + login: 'xy', // different editor + }, + }, + ], + }, + }, + }, + }; + nock('https://api.github.com') + .post('/graphql', { + query: commentsReq, + variables: { + repoName: 'some-repo', + repoOwner: 'some-org', + prNumber: 9500, + }, + }) + .reply(200, { data: response }); + + nock('https://api.github.com') + .post('/graphql', { + query: mutationReq, + variables: { subjectId: 'B' }, + }) + .reply(200, { data: { clientMutationId: null } }); + nock('https://api.github.com') + .post('/graphql', { + query: mutationReq, + variables: { subjectId: 'C' }, + }) + .reply(200, { data: { clientMutationId: null } }); + const res = await handler(lambdaRequest, context); assertLambdaResponse(res, 200, { success: true, @@ -694,6 +752,20 @@ describe('github-control', () => { ) .reply(200, updateCommentReply); + const response: CommentsRequestData = { + self: { login: 'xx' }, + comments: { + pullRequest: { + comments: { + nodes: [], + }, + }, + }, + }; + nock('https://api.github.com') + .post('/graphql') + .reply(200, { data: response }); + const res = await handler(lambdaRequestMultiBuild, context); assertLambdaResponse(res, 200, { success: true, @@ -749,6 +821,20 @@ describe('github-control', () => { ) .reply(200, updateCommentReply); + const response: CommentsRequestData = { + self: { login: 'xx' }, + comments: { + pullRequest: { + comments: { + nodes: [], + }, + }, + }, + }; + nock('https://api.github.com') + .post('/graphql') + .reply(200, { data: response }); + const res = await handler(lambdaRequest, context); assertLambdaResponse(res, 200, { success: true, @@ -827,6 +913,20 @@ describe('github-control', () => { ) .reply(200, updateCommentReply); + const response: CommentsRequestData = { + self: { login: 'xx' }, + comments: { + pullRequest: { + comments: { + nodes: [], + }, + }, + }, + }; + nock('https://api.github.com') + .post('/graphql') + .reply(200, { data: response }); + const res = await handler(lambdaRequest, context); assertLambdaResponse(res, 200, { success: true, diff --git a/docs/guides/install-and-configure.md b/docs/guides/install-and-configure.md index 1dab45b..77d93cf 100644 --- a/docs/guides/install-and-configure.md +++ b/docs/guides/install-and-configure.md @@ -44,6 +44,7 @@ type Config = { BUILDKITE_ORG_NAME: string; // (the URL part of `https://buildkite.com//`) ENABLE_DEBUG?: string; // defaults to "false" GITHUB_WEBHOOK_SECRET?: string; // A webhook secret to verify the webhook request against + COLLAPSE_OLD_COMMENTS?: string; // defaults to "true"; collapses old RocketBot comments when adding/editing new ones } & ( | { // RocketBot uses a GitHub app. Recommended. diff --git a/package.json b/package.json index 1adb983..8daef32 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "license": "MIT", "private": true, "devDependencies": { + "@octokit/graphql-schema": "^10.39.1", "@octokit/types": "^6.14.2", "@octokit/webhooks-schemas": "^3.72.0", "@octokit/webhooks-types": "^3.72.0", @@ -68,6 +69,7 @@ }, "dependencies": { "@octokit/auth-app": "^3.4.0", + "@octokit/graphql": "^4.6.1", "@octokit/rest": "^18.5.3", "@octokit/webhooks-methods": "^1.0.0", "aws-lambda": "^1.0.6", diff --git a/src/buildkite.ts b/src/buildkite.ts index 705dd3b..1816fd8 100644 --- a/src/buildkite.ts +++ b/src/buildkite.ts @@ -31,9 +31,7 @@ export async function buildkiteReadPipelines( paginate: { all }, } = gotInstance(config); - const pipelines = await all('pipelines?page=1&per_page=100'); - logger.debug('All pipelines: %o', pipelines); - return pipelines; + return all('pipelines?page=1&per_page=100'); } export type Author = { diff --git a/src/config.ts b/src/config.ts index c7a9f58..c5aac6f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ const BaseConfig = z.object({ BUILDKITE_ORG_NAME: z.string(), ENABLE_DEBUG: z.string().optional(), [GITHUB_WEBHOOK_SECRET_KEY]: z.string().optional(), + COLLAPSE_OLD_COMMENTS: z.string().optional().default('true'), }); export const Config = z.union([ @@ -46,6 +47,7 @@ export const getConfig = async function ( config.GITHUB_WEBHOOK_SECRET ?? process.env[GITHUB_WEBHOOK_SECRET_KEY]; return config; } else { - return Config.parse(env); + const config = Config.parse(env); + return config; } }; diff --git a/src/events/commented.ts b/src/events/commented.ts index 8a49c99..eb8d85c 100644 --- a/src/events/commented.ts +++ b/src/events/commented.ts @@ -2,6 +2,7 @@ import { IssueCommentEvent, PullRequest, PullRequestReviewCommentEvent, + Repository, } from '@octokit/webhooks-types'; import { isTriggerComment, parseTriggerComment } from '../trigger'; import { Logger } from 'pino'; @@ -12,20 +13,40 @@ import { githubGetPullRequestDetails, githubUpdateComment, } from '../github'; -import type { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; +import type { RestEndpointMethodTypes } from '@octokit/rest'; import type { JSONResponse } from '../response'; +import { GithubApis } from '../github_apis'; +import { IssueComment, Mutation, Scalars, User } from '@octokit/graphql-schema'; export type PullRequestData = RestEndpointMethodTypes['pulls']['get']['response']['data']; export type UserData = RestEndpointMethodTypes['users']['getByUsername']['response']['data']; +type PullRequestContext = PullRequestData | PullRequest; +type Unpromisify = T extends Promise ? U : T; +type ID = Scalars['ID']; + +export type CommentsRequestData = { + self: Pick; + comments: { + pullRequest: { + comments: { + nodes: (Pick & { + editor?: Pick; + })[]; + }; + }; + }; +}; + const envVarPrefix = 'GH_CONTROL_USER_ENV_'; +const BOT_SUFFIX = '[bot]'; export async function commented( eventBody: IssueCommentEvent | PullRequestReviewCommentEvent, currentEventType: 'issue_comment' | 'pull_request_review_comment', logger: Logger, config: Config, - octokit: Octokit, + apis: GithubApis, ): Promise { if (eventBody.action === 'deleted') { logger.info('Comment was deleted, nothing to do here'); @@ -45,16 +66,17 @@ export async function commented( return { success: true, triggered: false }; } - const prHtmlUrl = isIssueComment(currentEventType, eventBody) - ? eventBody.issue.pull_request?.html_url - : eventBody.pull_request.html_url; + const pr = isIssueComment(currentEventType, eventBody) + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + eventBody.issue.pull_request! // we know it came from a PR, otherwise we'd have exited above + : eventBody.pull_request; const requestedBuildData = parseTriggerComment(eventBody.comment.body); const commentUrl = eventBody.comment.url; const commenter = eventBody.sender.login; logger.info( `@${commenter} requested "${requestedBuildData.buildNames.join(',')}" for ${ - prHtmlUrl || 'unkown URL' + pr.html_url }`, ); @@ -69,8 +91,9 @@ export async function commented( {}, ); + const { octokit } = apis; const [prData, { name: senderName, email: senderEmail }] = await Promise.all< - PullRequestData | PullRequest, + PullRequestContext, UserData >([ isIssueComment(currentEventType, eventBody) @@ -143,6 +166,15 @@ ${requestedBuildData.buildNames updatedComment, ); logger.info(`Updated comment ${commentData.html_url} with build URL`); + if (config.COLLAPSE_OLD_COMMENTS === 'true') { + await collapseOldComments( + apis, + prData, + eventBody.repository, + commentData, + logger, + ); + } return { success: true, triggered: true, @@ -150,3 +182,93 @@ ${requestedBuildData.buildNames updatedCommentUrl: commentData.html_url, }; } + +/** + * Collapses comments for and from RocketBot prior to the current one + */ +async function collapseOldComments( + { graphql }: GithubApis, + prData: PullRequestContext, + repository: Repository, + currentComment: Unpromisify>, + logger: Logger, +) { + const res = await graphql(commentsReq, { + repoName: repository.name, + repoOwner: repository.owner.login, + prNumber: prData.number, + }); + + const currentUser = stripBotSuffix(res.self.login); + logger.info('current user is "%s"', currentUser); + const { nodes: comments } = res.comments.pullRequest.comments; + logger.info('current comment ID is "%s"', currentComment.node_id); + + const toCollapse: ID[] = comments.reduce((acc, comment) => { + if (comment.isMinimized) { + // ignore all minimized + return acc; + } + + if (comment.id === currentComment.node_id) { + // ignore the current comment (the one that has triggered this run) + return acc; + } + + if (comment.viewerDidAuthor) { + // author was rocketbot, collapse + return [...acc, comment.id]; + } + if (comment.editor?.login === currentUser) { + // last editor was rocketbot, collapse + return [...acc, comment.id]; + } + return acc; + }, []); + + logger.info('Collapsing comment IDs: %o', toCollapse); + + await Promise.all( + toCollapse.map((subjectId) => + graphql>(mutationReq, { subjectId }), + ), + ); +} + +export const commentsReq = ` +query($repoName: String!, $repoOwner: String!, $prNumber: Int!) { + self: viewer { + login + } + comments: repository(name: $repoName, owner: $repoOwner) { + pullRequest(number: $prNumber) { + comments(last: 10) { + nodes { + id + viewerDidAuthor + editor { + login + } + isMinimized + } + } + } + } +} +`; + +export const mutationReq = ` +mutation($subjectId: ID!) { + minimizeComment( + input: { subjectId: $subjectId, classifier: OUTDATED } + ) { + clientMutationId + } +} +`; + +function stripBotSuffix(login: string) { + return login.endsWith(BOT_SUFFIX) + ? login.substr(0, login.length - BOT_SUFFIX.length) + : login; +} diff --git a/src/events/pr_opened.ts b/src/events/pr_opened.ts index 2d3e5ee..022f41f 100644 --- a/src/events/pr_opened.ts +++ b/src/events/pr_opened.ts @@ -4,10 +4,10 @@ import { Config } from '../config'; import { buildkiteReadPipelines, Pipeline } from '../buildkite'; import { fetchDocumentationLinkMds } from '../initial_comment'; import { githubAddComment } from '../github'; -import { Octokit } from '@octokit/rest'; import sortBy from 'lodash.sortby'; import { JSONResponse } from '../response'; import gitUrlParse, { GitUrl } from 'git-url-parse'; +import { GithubApis } from '../github_apis'; const validBranchBuildEnvVarMarker = 'GH_CONTROL_IS_VALID_BRANCH_BUILD'; @@ -35,7 +35,7 @@ export async function prOpened( eventBody: PullRequestEvent, logger: Logger, config: Config, - octokit: Octokit, + apis: GithubApis, ): Promise { if (eventBody.action !== 'opened') { logger.info('PR was not opened, nothing to do here'); @@ -62,6 +62,7 @@ export async function prOpened( }; } + const { octokit } = apis; const links = await fetchDocumentationLinkMds( octokit, logger, diff --git a/src/octokit.ts b/src/github_apis.ts similarity index 52% rename from src/octokit.ts rename to src/github_apis.ts index 139b367..3c946a6 100644 --- a/src/octokit.ts +++ b/src/github_apis.ts @@ -4,6 +4,9 @@ import memoizeOne from 'memoize-one'; import type { Logger } from 'pino'; import type { Config } from './config'; import { Octokit } from '@octokit/rest'; +import { graphql } from '@octokit/graphql'; + +export type GithubApis = { octokit: Octokit; graphql: typeof graphql }; /** * Type guard for Octokit request errors @@ -18,10 +21,10 @@ export function isOctokitRequestError(e: unknown): e is RequestError { ); } -export const getOctokit = memoizeOne(async function ( +export const getGithubApis = memoizeOne(async function ( config: Config, logger: Logger, -) { +): Promise { const octokitBaseConfig = { log: { debug: (message: string) => logger.debug(message), @@ -33,20 +36,35 @@ export const getOctokit = memoizeOne(async function ( if ('GITHUB_APP_APP_ID' in config) { logger.debug('Using app credentials'); - return new Octokit({ - ...octokitBaseConfig, - authStrategy: createAppAuth, - auth: { - appId: config.GITHUB_APP_APP_ID, - privateKey: config.GITHUB_APP_PRIVATE_KEY, - installationId: config.GITHUB_APP_INSTALLATION_ID, - }, - }); + const auth = { + appId: config.GITHUB_APP_APP_ID, + privateKey: config.GITHUB_APP_PRIVATE_KEY, + installationId: config.GITHUB_APP_INSTALLATION_ID, + }; + return { + octokit: new Octokit({ + ...octokitBaseConfig, + authStrategy: createAppAuth, + auth, + }), + graphql: graphql.defaults({ + request: { + hook: createAppAuth(auth).hook, + }, + }), + }; } else { logger.debug('Using user credentials'); - return new Octokit({ - ...octokitBaseConfig, - auth: config.GITHUB_TOKEN, - }); + return { + octokit: new Octokit({ + ...octokitBaseConfig, + auth: config.GITHUB_TOKEN, + }), + graphql: graphql.defaults({ + headers: { + authorization: `token ${config.GITHUB_TOKEN}`, + }, + }), + }; } }); diff --git a/src/index.ts b/src/index.ts index b64d517..160b73a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { ok } from 'assert'; import type { PinoLambdaLogger } from 'pino-lambda'; import pino from 'pino-lambda'; import { Config, getConfig } from './config'; -import { getOctokit, isOctokitRequestError } from './octokit'; +import { getGithubApis, isOctokitRequestError } from './github_apis'; import { commented } from './events/commented'; import type { JSONResponse } from './response'; import { ping } from './events/ping'; @@ -43,7 +43,7 @@ export const handler = async ( ): Promise => { const config = await getConfig(process.env); const logger = getLogger(config); - const octokit = await getOctokit(config, logger); + const apis = await getGithubApis(config, logger); logger.withRequest( { @@ -58,7 +58,10 @@ export const handler = async ( logger.debug('Received event: %o', event); - if (typeof config.GITHUB_WEBHOOK_SECRET !== 'undefined') { + if ( + typeof config.GITHUB_WEBHOOK_SECRET !== 'undefined' && + config.GITHUB_WEBHOOK_SECRET !== '' + ) { logger.info('Verifying request signature'); const isValidSignature = await hasValidSignature( config.GITHUB_WEBHOOK_SECRET, @@ -100,7 +103,7 @@ export const handler = async ( body as WebhookEventMap[typeof currentEventType], logger, config, - octokit, + apis, ), ); } @@ -113,7 +116,7 @@ export const handler = async ( currentEventType, logger, config, - octokit, + apis, ), ); } diff --git a/src/initial_comment.ts b/src/initial_comment.ts index e790264..b917f6f 100644 --- a/src/initial_comment.ts +++ b/src/initial_comment.ts @@ -2,7 +2,7 @@ import type { Logger } from 'pino'; import type { Octokit } from '@octokit/rest'; import type { PullRequest, Repository } from '@octokit/webhooks-types'; import type { Pipeline } from './buildkite'; -import { isOctokitRequestError } from './octokit'; +import { isOctokitRequestError } from './github_apis'; import type { RestEndpointMethodTypes } from '@octokit/rest'; type Dict = Record; diff --git a/src/trigger.ts b/src/trigger.ts index 0aa682f..8960593 100644 --- a/src/trigger.ts +++ b/src/trigger.ts @@ -66,7 +66,10 @@ function parseEnvBlock( */ export function parseTriggerComment( commentBody: string, -): { buildNames: string[]; env: NodeJS.ProcessEnv } { +): { + buildNames: string[]; + env: NodeJS.ProcessEnv; +} { const match = commentBody.match(buildTriggerRegex); // TODO: ensure that matches are mapped properly - either with named captures and/or non-capturing groups const pipelinesBlock = match ? match[0] : ''; diff --git a/yarn.lock b/yarn.lock index ee2d5a3..e058fd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -610,7 +610,15 @@ is-plain-object "^5.0.0" universal-user-agent "^6.0.0" -"@octokit/graphql@^4.5.8": +"@octokit/graphql-schema@^10.39.1": + version "10.39.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql-schema/-/graphql-schema-10.39.1.tgz#06d6c2fd102b531f5c7263a12fdac911713b8c6a" + integrity sha512-ZTHQCVycFaGKtOw94RTWjCKSLY3lMk+GRTuFotmmCk8WABBF3XQtH1wdxIcpH3cvluWop8YEtrtb1OggSQ6F4A== + dependencies: + graphql "^15.0.0" + graphql-tag "^2.10.3" + +"@octokit/graphql@^4.5.8", "@octokit/graphql@^4.6.1": version "4.6.1" resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.6.1.tgz#f975486a46c94b7dbe58a0ca751935edc7e32cc9" integrity sha512-2lYlvf4YTDgZCTXTW4+OX+9WTLFtEUc6hGm4qM1nlZjzxj+arizM4aHWzBVBCxY9glh7GIs0WEuiSgbVzv8cmA== @@ -2693,6 +2701,18 @@ graceful-fs@^4.1.2, graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== +graphql-tag@^2.10.3: + version "2.12.4" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.4.tgz#d34066688a4f09e72d6f4663c74211e9b4b7c4bf" + integrity sha512-VV1U4O+9x99EkNpNmCUV5RZwq6MnK4+pGbRYWG+lA/m3uo7TSqJF81OkcOP148gFP6fzdl7JWYBrwWVTS9jXww== + dependencies: + tslib "^2.1.0" + +graphql@^15.0.0: + version "15.5.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" + integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" @@ -5531,7 +5551,7 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.2.0: +tslib@^2.1.0, tslib@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==