// // Copyright © 2023 Hardcore Engineering Inc. // import { PersonAccount } from '@hcengineering/contact' import core, { Account, AttachedData, Doc, DocData, DocumentUpdate, MeasureContext, Ref, TxOperations } from '@hcengineering/core' import { LiveQuery } from '@hcengineering/query' import github, { DocSyncInfo, GithubIntegrationRepository, GithubProject, GithubReviewComment } from '@hcengineering/github' import { ContainerFocus, DocSyncManager, ExternalSyncField, IntegrationContainer, IntegrationManager, githubExternalSyncVersion, githubSyncVersion } from '../types' import { ReviewComment as ReviewCommentExternalData, reviewCommentDetails } from './githubTypes' import { collectUpdate, deleteObjects, errorToObj, isGHWriteAllowed } from './utils' import { Analytics } from '@hcengineering/analytics' import { PullRequestReviewCommentCreatedEvent, PullRequestReviewCommentEvent } from '@octokit/webhooks-types' import config from '../config' import { syncConfig } from './syncConfig' export type ReviewCommentData = DocData export class ReviewCommentSyncManager implements DocSyncManager { provider!: IntegrationManager createCommentPromise: Promise> | undefined externalDerivedSync = false constructor ( readonly ctx: MeasureContext, readonly client: TxOperations, readonly lq: LiveQuery ) {} async init (provider: IntegrationManager): Promise { this.provider = provider } eventSync = new Map>() async handleEvent(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise { await this.createCommentPromise const event = evt as PullRequestReviewCommentEvent if (event.sender.type === 'Bot') { // Ignore events from Bot if it is our bot // No need to handle event from ourself if (event.sender.login.includes(config.BotName)) { return } } this.ctx.info('reviewComments:handleEvent', { action: event.action, login: event.sender.login, workspace: this.provider.getWorkspaceId().name }) const { project, repository } = await this.provider.getProjectAndRepository(event.repository.node_id) if (project === undefined || repository === undefined) { this.ctx.info('No project for repository', { name: event.repository.name, workspace: this.provider.getWorkspaceId().name }) return } await this.eventSync.get(event.comment.html_url) const promise = this.processEvent(event, derivedClient, repository, integration) this.eventSync.set(event.comment.html_url, promise) await promise this.eventSync.delete(event.comment.html_url) } async handleDelete ( existing: Doc | undefined, info: DocSyncInfo, derivedClient: TxOperations, deleteExisting: boolean, parent?: DocSyncInfo ): Promise { const container = await this.provider.getContainer(info.space) if (container === undefined) { return false } if ( container?.container === undefined || ((container.project.projectNodeId === undefined || !container.container.projectStructure.has(container.project._id)) && syncConfig.MainProject) ) { return false } const commentExternal = info.external if (commentExternal === undefined) { // No external issue yet, safe delete, since platform document will be deleted a well. return true } const account = existing?.createdBy ?? (await this.provider.getAccountU(commentExternal.user))?._id ?? core.account.System if (commentExternal !== undefined) { try { await this.deleteGithubDocument(container, account, commentExternal.node_id, derivedClient, parent) } catch (err: any) { let cnt = false if (Array.isArray(err.errors)) { for (const e of err.errors) { if (e.type === 'NOT_FOUND') { // Ok issue is already deleted cnt = true break } } } if (!cnt) { this.ctx.error('Error', { err }) Analytics.handleError(err) await derivedClient.update(info, { error: errorToObj(err) }) } } } if (existing !== undefined && deleteExisting) { await deleteObjects(this.ctx, this.client, [existing], account) } return true } async deleteGithubDocument ( container: ContainerFocus, account: Ref, id: string, derivedClient: TxOperations, parent?: DocSyncInfo ): Promise { const okit = (await this.provider.getOctokit(account as Ref)) ?? container.container.octokit const q = `mutation deleteReviewComment($reviewID: ID!) { deletePullRequestReviewComment(input: { id: $reviewID }) { pullRequestReview { url } } }` if (isGHWriteAllowed()) { await okit?.graphql(q, { reviewID: id }) } if (parent !== undefined) { // We need to force pull request update to sync review content properly. await derivedClient.update(parent, { externalVersion: '', derivedVersion: '' }) } } private async processEvent ( event: PullRequestReviewCommentEvent, derivedClient: TxOperations, repo: GithubIntegrationRepository, integration: IntegrationContainer ): Promise { const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System let externalData: ReviewCommentExternalData try { const response: any = await integration.octokit?.graphql( ` query listReview($reviewID: ID!) { node(id: $reviewID) { ... on PullRequestReviewComment { ${reviewCommentDetails} } } } `, { reviewID: event.comment.node_id } ) externalData = response.node } catch (err: any) { this.ctx.error('Error', { err }) Analytics.handleError(err) return } if (externalData === undefined) { return } switch (event.action) { case 'created': { await this.createSyncData(event, derivedClient, repo, externalData) break } case 'deleted': { const reviewData = await this.client.findOne(github.class.DocSyncInfo, { space: repo.githubProject as Ref, url: (event.comment.html_url ?? '').toLowerCase() }) if (reviewData !== undefined) { await derivedClient.update( reviewData, { deleted: true, needSync: '' }, false, Date.now(), account ) this.provider.sync() } break } case 'edited': { const reviewData = await this.client.findOne(github.class.DocSyncInfo, { space: repo.githubProject as Ref, url: (event.comment.html_url ?? '').toLowerCase() }) if (reviewData !== undefined) { const reviewObj: GithubReviewComment | undefined = await this.client.findOne( reviewData.objectClass, { _id: reviewData._id as unknown as Ref } ) if (reviewObj !== undefined) { const lastModified = Date.now() const body = await this.provider.getMarkup(integration, event.comment.body) await derivedClient.diffUpdate( reviewData, { external: externalData, externalVersion: githubExternalSyncVersion, current: { ...reviewData.current, body }, needSync: githubSyncVersion, lastModified }, lastModified ) await this.client.update( reviewObj, { body }, false, lastModified, account ) this.provider.sync() } } break } } } private async createSyncData ( createdEvent: PullRequestReviewCommentCreatedEvent, derivedClient: TxOperations, repo: GithubIntegrationRepository, externalData: ReviewCommentExternalData ): Promise { const reviewData = await this.client.findOne(github.class.DocSyncInfo, { space: repo.githubProject as Ref, url: (createdEvent.comment.html_url ?? '').toLowerCase() }) if (reviewData === undefined) { await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref, { url: (createdEvent.comment.html_url ?? '').toLowerCase(), needSync: '', githubNumber: 0, repository: repo._id, objectClass: github.class.GithubReviewComment, external: externalData, externalVersion: githubExternalSyncVersion, parent: (createdEvent.pull_request.html_url ?? '').toLowerCase(), lastModified: new Date(createdEvent.comment.updated_at ?? Date.now()).getTime() }) this.provider.sync() } } async sync ( existing: Doc | undefined, info: DocSyncInfo, parent: DocSyncInfo | undefined, derivedClient: TxOperations ): Promise | undefined> { const container = await this.provider.getContainer(info.space) if (container?.container === undefined) { return { needSync: githubSyncVersion } } if (parent === undefined) { return { needSync: githubSyncVersion } } if (info.external === undefined) { // TODO: Use selected repository const repo = await this.provider.getRepositoryById(parent?.repository) if (repo?.nodeId === undefined) { // No need to sync if parent repository is not defined. return { needSync: githubSyncVersion } } // If no external document, we need to create it. this.createCommentPromise = this.createGithubReviewComment(container, existing, info, parent, derivedClient) return await this.createCommentPromise } const reviewComment = info.external as ReviewCommentExternalData const account = existing?.modifiedBy ?? (await this.provider.getAccount(reviewComment.author))?._id ?? core.account.System if (info.reviewThreadId === undefined && reviewComment.replyTo?.url !== undefined) { const rthread = await derivedClient.findOne(github.class.GithubReviewComment, { space: container.project._id, url: reviewComment.replyTo?.url?.toLowerCase() }) if (rthread !== undefined && info.reviewThreadId !== rthread.reviewThreadId) { info.reviewThreadId = rthread.reviewThreadId await derivedClient.update(info, { reviewThreadId: info.reviewThreadId }) } } const messageData: ReviewCommentData = { body: await this.provider.getMarkup(container.container, reviewComment.body), diffHunk: reviewComment.diffHunk, isMinimized: reviewComment.isMinimized, reviewUrl: reviewComment.pullRequestReview.url, line: reviewComment.line, startLine: reviewComment.startLine, originalLine: reviewComment.originalLine, outdated: reviewComment.outdated, path: reviewComment.path, url: reviewComment.url.toLowerCase(), minimizedReason: reviewComment.minimizedReason, includesCreatedEdit: reviewComment.includesCreatedEdit, originalStartLine: reviewComment.originalLine, replyToUrl: reviewComment.replyTo?.url, reviewThreadId: info.reviewThreadId } if (existing === undefined) { try { await this.createReviewComment(info, messageData, parent, reviewComment, account) return { needSync: githubSyncVersion, current: messageData } } catch (err: any) { this.ctx.error('Error', { err }) Analytics.handleError(err) return { needSync: githubSyncVersion, error: errorToObj(err) } } } else { await this.handleDiffUpdate(existing, info, messageData, container, parent, reviewComment, account, derivedClient) } return { current: messageData, needSync: githubSyncVersion } } private async handleDiffUpdate ( existing: Doc, info: DocSyncInfo, reviewCommentData: ReviewCommentData, container: ContainerFocus, parent: DocSyncInfo, reviewComment: ReviewCommentExternalData, account: Ref, derivedClient: TxOperations ): Promise { const repository = await this.provider.getRepositoryById(info.repository) if (repository === undefined) { return } const existingReview = existing as GithubReviewComment const previousData: ReviewCommentData = info.current ?? ({} as unknown as ReviewCommentData) const update = collectUpdate(previousData, reviewCommentData, Object.keys(reviewCommentData)) const platformUpdate = collectUpdate(previousData, existing, Object.keys(reviewCommentData)) // We should remove changes we already have from github changed. for (const [k, v] of Object.entries(update)) { if ((platformUpdate as any)[k] !== v) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (platformUpdate as any)[k] } } // Remove current same values from update for (const [k, v] of Object.entries(existingReview)) { if ((update as any)[k] === v) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (update as any)[k] } } if (Object.keys(platformUpdate).length > 0) { if (platformUpdate.body !== undefined) { const body = await this.provider.getMarkup(container.container, platformUpdate.body) const okit = (await this.provider.getOctokit(account as Ref)) ?? container.container.octokit const q = `mutation updateReviewComment($commentID: ID!, $body: String!) { updatePullRequestReviewComment(input: { threadId: $threadID }) { pullRequestReviewComment { id } }` if (isGHWriteAllowed()) { await okit?.graphql(q, { threadID: reviewComment.id, body }) } await derivedClient.update(info, { external: { ...info.external, body } }) } } if (Object.keys(update).length > 0) { await this.client.update( existing, update, false, new Date(reviewComment.updatedAt ?? Date.now()).getTime(), account ) } } private async createReviewComment ( info: DocSyncInfo, messageData: ReviewCommentData, parent: DocSyncInfo, review: ReviewCommentExternalData, account: Ref ): Promise { const _id: Ref = info._id as unknown as Ref const value: AttachedData = { ...messageData } await this.client.addCollection( github.class.GithubReviewComment, info.space, parent._id, parent.objectClass, 'reviewComments', value, _id, new Date(review.createdAt ?? Date.now()).getTime(), account ) } async createGithubReviewComment ( container: ContainerFocus, existing: Doc | undefined, info: DocSyncInfo, parent: DocSyncInfo, derivedClient: TxOperations ): Promise> { // TODO: Use selected repository const repo = await this.provider.getRepositoryById(parent?.repository) if (repo?.nodeId === undefined) { // No need to sync if parent repository is not defined. return { needSync: githubSyncVersion } } if (parent === undefined) { return {} } const existingReview = existing as GithubReviewComment const okit = (await this.provider.getOctokit(existingReview.modifiedBy as Ref)) ?? container.container.octokit // No external version yet, create it. try { const q = `mutation createComment($prID: ID!, $body: String!) { addPullRequestReviewThreadReply(input:{ pullRequestReviewThreadId: $prID, body: $body }) { comment { ${reviewCommentDetails} } } }` if (isGHWriteAllowed()) { const response: | { addPullRequestReviewThreadReply: { comment: ReviewCommentExternalData } } | undefined = await okit?.graphql(q, { prID: existingReview.reviewThreadId, body: (await this.provider.getMarkdown(existingReview.body)) ?? '' }) const reviewExternal = response?.addPullRequestReviewThreadReply?.comment if (reviewExternal !== undefined) { const upd: DocumentUpdate = { url: reviewExternal.url.toLowerCase(), external: reviewExternal, current: existing, repository: repo._id, parent: parent.url.toLocaleLowerCase(), needSync: githubSyncVersion, externalVersion: githubExternalSyncVersion, reviewThreadId: info.reviewThreadId ?? existingReview.reviewThreadId } // We need to update in current promise, to prevent event changes. await derivedClient.update(info, upd) await this.client.update(existingReview, { diffHunk: reviewExternal.diffHunk, isMinimized: reviewExternal.isMinimized, reviewUrl: reviewExternal.pullRequestReview.url, line: reviewExternal.line, startLine: reviewExternal.startLine, originalLine: reviewExternal.originalLine, outdated: reviewExternal.outdated, path: reviewExternal.path, url: reviewExternal.url.toLowerCase(), minimizedReason: reviewExternal.minimizedReason, includesCreatedEdit: reviewExternal.includesCreatedEdit, originalStartLine: reviewExternal.originalLine, replyToUrl: reviewExternal.replyTo?.url, reviewThreadId: info.reviewThreadId ?? existingReview.reviewThreadId }) } } return {} } catch (err: any) { this.ctx.error('Error', { err }) Analytics.handleError(err) return { needSync: githubSyncVersion, error: errorToObj(err) } } } async externalSync ( integration: IntegrationContainer, derivedClient: TxOperations, kind: ExternalSyncField, syncDocs: DocSyncInfo[], repository: GithubIntegrationRepository, project: GithubProject ): Promise { // No need to perform external sync for reviews, so let's update marks const tx = derivedClient.apply() for (const d of syncDocs) { await tx.update(d, { externalVersion: githubExternalSyncVersion }) } await tx.commit() this.provider.sync() } repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {} async externalFullSync ( integration: IntegrationContainer, derivedClient: TxOperations, projects: GithubProject[], repositories: GithubIntegrationRepository[] ): Promise { // No external sync for reviews, they are done in pull requests. } }