mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-26 14:22:23 +02:00
Move services to public (#6156)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import chunter, { ChatMessage } from '@hcengineering/chunter'
|
||||
import { PersonAccount } from '@hcengineering/contact'
|
||||
import core, {
|
||||
Account,
|
||||
AttachedData,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
MeasureContext,
|
||||
Ref,
|
||||
TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import { LiveQuery } from '@hcengineering/query'
|
||||
import github, { DocSyncInfo, GithubIntegrationRepository, GithubProject } from '@hcengineering/github'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import {
|
||||
ContainerFocus,
|
||||
DocSyncManager,
|
||||
ExternalSyncField,
|
||||
IntegrationContainer,
|
||||
IntegrationManager,
|
||||
githubExternalSyncVersion,
|
||||
githubSyncVersion
|
||||
} from '../types'
|
||||
import { collectUpdate, deleteObjects, errorToObj, getSince, isGHWriteAllowed } from './utils'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { IssueComment, IssueCommentCreatedEvent, IssueCommentEvent } from '@octokit/webhooks-types'
|
||||
import config from '../config'
|
||||
import { syncConfig } from './syncConfig'
|
||||
|
||||
interface MessageData {
|
||||
message: string
|
||||
}
|
||||
|
||||
type CommentExternalData = Omit<IssueComment, 'author_association' | 'performed_via_github_app'>
|
||||
|
||||
export class CommentSyncManager implements DocSyncManager {
|
||||
provider!: IntegrationManager
|
||||
|
||||
createCommentPromise: Promise<DocumentUpdate<DocSyncInfo>> | undefined
|
||||
|
||||
externalDerivedSync = false
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly client: TxOperations,
|
||||
readonly lq: LiveQuery
|
||||
) {}
|
||||
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
eventSync = new Map<string, Promise<void>>()
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
||||
await this.createCommentPromise
|
||||
const event = evt as IssueCommentEvent
|
||||
this.ctx.info('comments:handleEvent', {
|
||||
action: event.action,
|
||||
login: event.sender.login,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
await this.eventSync.get(event.issue.url)
|
||||
const promise = this.processEvent(event, derivedClient, integration)
|
||||
this.eventSync.set(event.issue.url, promise)
|
||||
await promise
|
||||
this.eventSync.delete(event.issue.url)
|
||||
}
|
||||
|
||||
async handleDelete (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean
|
||||
): Promise<boolean> {
|
||||
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 as CommentExternalData | undefined
|
||||
|
||||
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)
|
||||
} 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) {
|
||||
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<Account>, id: string): Promise<void> {
|
||||
const okit = (await this.provider.getOctokit(account as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
|
||||
const q = `mutation deleteComment($commentID: ID!) {
|
||||
deleteIssueComment(
|
||||
input: {id: $commentID}
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
if (isGHWriteAllowed()) {
|
||||
await okit?.graphql(q, {
|
||||
commentID: id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async processEvent (
|
||||
event: IssueCommentEvent,
|
||||
derivedClient: TxOperations,
|
||||
integration: IntegrationContainer
|
||||
): Promise<void> {
|
||||
const { repository } = await this.provider.getProjectAndRepository(event.repository.node_id)
|
||||
if (repository === undefined) {
|
||||
this.ctx.info('No project for repository', {
|
||||
repository: event.repository,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System
|
||||
switch (event.action) {
|
||||
case 'created': {
|
||||
await this.createSyncData(event, derivedClient, repository)
|
||||
break
|
||||
}
|
||||
case 'deleted': {
|
||||
const syncData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (event.comment.url ?? '').toLowerCase()
|
||||
})
|
||||
if (syncData !== undefined) {
|
||||
await derivedClient.update<DocSyncInfo>(syncData, { deleted: true, needSync: '' })
|
||||
this.provider.sync()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'edited': {
|
||||
const commentData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (event.comment.url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
const messageData: MessageData = {
|
||||
message: await this.provider.getMarkup(integration, event.comment.body)
|
||||
}
|
||||
|
||||
if (commentData !== undefined) {
|
||||
const chatMessage: ChatMessage | undefined = await this.client.findOne<ChatMessage>(commentData.objectClass, {
|
||||
_id: commentData._id as unknown as Ref<ChatMessage>
|
||||
})
|
||||
if (chatMessage !== undefined) {
|
||||
const lastModified = new Date(event.comment.updated_at).getTime()
|
||||
await derivedClient.diffUpdate(
|
||||
commentData,
|
||||
{
|
||||
external: event.comment,
|
||||
current: messageData,
|
||||
needSync: githubSyncVersion,
|
||||
lastModified
|
||||
},
|
||||
lastModified
|
||||
)
|
||||
await this.client.diffUpdate(chatMessage, messageData, lastModified, account)
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createSyncData (
|
||||
createdEvent: IssueCommentCreatedEvent,
|
||||
derivedClient: TxOperations,
|
||||
repo: GithubIntegrationRepository
|
||||
): Promise<void> {
|
||||
const commentData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (createdEvent.comment.url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
if (commentData === undefined) {
|
||||
await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref<GithubProject>, {
|
||||
url: (createdEvent.comment.url ?? '').toLowerCase(),
|
||||
needSync: '',
|
||||
githubNumber: 0,
|
||||
repository: repo._id,
|
||||
objectClass: chunter.class.ChatMessage,
|
||||
external: createdEvent.comment as CommentExternalData,
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
parent: createdEvent.issue.url,
|
||||
lastModified: new Date(createdEvent.comment.updated_at).getTime()
|
||||
})
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
|
||||
async sync (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo | undefined,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
const container = await this.provider.getContainer(info.space)
|
||||
if (container?.container === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (info.external === undefined) {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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.createGithubComment(container, existing, info, parent, derivedClient)
|
||||
return await this.createCommentPromise
|
||||
}
|
||||
const comment = info.external as CommentExternalData
|
||||
if (parent === undefined) {
|
||||
// Find parent by issue url
|
||||
parent = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (comment.html_url.split('#')?.[0] ?? '').toLowerCase()
|
||||
})
|
||||
}
|
||||
if (parent === undefined) {
|
||||
// no Sync until parent is found, parent should trigger all child's refresh.
|
||||
return { needSync: githubSyncVersion }
|
||||
}
|
||||
|
||||
const account = existing?.modifiedBy ?? (await this.provider.getAccountU(comment.user))?._id ?? core.account.System
|
||||
|
||||
const messageData: MessageData = {
|
||||
message: await this.provider.getMarkup(container.container, comment.body)
|
||||
}
|
||||
if (existing === undefined) {
|
||||
try {
|
||||
await this.createComment(info, messageData, parent, comment, account)
|
||||
return { needSync: githubSyncVersion, current: messageData }
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
this.ctx.error(err)
|
||||
return { needSync: githubSyncVersion, error: errorToObj(err) }
|
||||
}
|
||||
} else {
|
||||
await this.handleDiffUpdate(existing, info, messageData, container, parent, comment, account)
|
||||
}
|
||||
return { current: messageData, needSync: githubSyncVersion }
|
||||
}
|
||||
|
||||
private async handleDiffUpdate (
|
||||
existing: Doc,
|
||||
info: DocSyncInfo,
|
||||
messageData: MessageData,
|
||||
container: ContainerFocus,
|
||||
parent: DocSyncInfo,
|
||||
comment: CommentExternalData,
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const repository = container.repository.find((it) => it._id === info.repository)
|
||||
if (repository === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingComment = existing as ChatMessage
|
||||
|
||||
const previousData: MessageData = info.current ?? ({} as unknown as MessageData)
|
||||
|
||||
const update = collectUpdate<ChatMessage>(previousData, messageData, Object.keys(messageData))
|
||||
|
||||
const platformUpdate = collectUpdate<ChatMessage>(previousData, existing, Object.keys(messageData))
|
||||
|
||||
// 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(existingComment)) {
|
||||
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) {
|
||||
// Check and update body with external
|
||||
const okit =
|
||||
(await this.provider.getOctokit(existing.modifiedBy as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
await okit?.rest.issues.updateComment({
|
||||
owner: repository.owner?.login as string,
|
||||
repo: repository.name,
|
||||
issue_number: parent.githubNumber,
|
||||
comment_id: comment.id,
|
||||
body: await this.provider.getMarkdown(existingComment.message),
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
})
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await this.client.update(existing, update, false, new Date(comment.updated_at).getTime(), account)
|
||||
}
|
||||
}
|
||||
|
||||
private async createComment (
|
||||
info: DocSyncInfo,
|
||||
messageData: MessageData,
|
||||
parent: DocSyncInfo,
|
||||
comment: CommentExternalData,
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const _id: Ref<ChatMessage> = info._id as unknown as Ref<ChatMessage>
|
||||
const value: AttachedData<ChatMessage> = {
|
||||
...messageData,
|
||||
attachments: 0
|
||||
}
|
||||
await this.client.addCollection(
|
||||
chunter.class.ChatMessage,
|
||||
info.space,
|
||||
parent._id,
|
||||
parent.objectClass,
|
||||
'comments',
|
||||
value,
|
||||
_id,
|
||||
new Date(comment.created_at).getTime(),
|
||||
account
|
||||
)
|
||||
}
|
||||
|
||||
async createGithubComment (
|
||||
container: ContainerFocus,
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo | undefined,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo>> {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === parent?.repository)
|
||||
if (repo?.nodeId === undefined) {
|
||||
// No need to sync if parent repository is not defined.
|
||||
return { needSync: githubSyncVersion }
|
||||
}
|
||||
|
||||
if (parent === undefined) {
|
||||
return {}
|
||||
}
|
||||
const chatMessage = existing as ChatMessage
|
||||
const okit =
|
||||
(await this.provider.getOctokit(chatMessage.modifiedBy as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
|
||||
// No external version yet, create it.
|
||||
try {
|
||||
const result = await okit?.rest.issues.createComment({
|
||||
owner: repo.owner?.login as string,
|
||||
repo: repo.name,
|
||||
issue_number: parent.githubNumber,
|
||||
body: await this.provider.getMarkdown(chatMessage.message),
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
})
|
||||
const upd: DocumentUpdate<DocSyncInfo> = {
|
||||
parent: result?.data.html_url?.split('#')?.[0] ?? '',
|
||||
url: (result?.data.url ?? '').toLowerCase(),
|
||||
external: result?.data as CommentExternalData,
|
||||
current: result?.data,
|
||||
repository: repo._id
|
||||
}
|
||||
// We need to update in current promise, to prevent event changes.
|
||||
await derivedClient.update(info, upd)
|
||||
return {}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
this.ctx.error(err)
|
||||
return { needSync: githubSyncVersion, error: errorToObj(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async externalSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
kind: ExternalSyncField,
|
||||
syncDocs: DocSyncInfo[],
|
||||
repository: GithubIntegrationRepository,
|
||||
project: GithubProject
|
||||
): Promise<void> {
|
||||
// No need to perform external sync for comments, so let's update marks
|
||||
const tx = derivedClient.apply('comments_github')
|
||||
for (const d of syncDocs) {
|
||||
await tx.update(d, { externalVersion: githubExternalSyncVersion })
|
||||
}
|
||||
await tx.commit()
|
||||
this.provider.sync()
|
||||
}
|
||||
|
||||
repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {
|
||||
integration.synchronized.delete(`${repo._id}:comment`)
|
||||
}
|
||||
|
||||
async externalFullSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
projects: GithubProject[],
|
||||
repositories: GithubIntegrationRepository[]
|
||||
): Promise<void> {
|
||||
for (const repo of repositories) {
|
||||
const syncKey = `${repo._id}:comment`
|
||||
if (repo.githubProject === undefined || !repo.enabled || integration.synchronized.has(syncKey)) {
|
||||
if (!repo.enabled) {
|
||||
integration.synchronized.delete(syncKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const prj = projects.find((it) => repo.githubProject === it._id)
|
||||
if (prj === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait global project sync
|
||||
await integration.syncLock.get(prj._id)
|
||||
|
||||
const since = await getSince(this.client, chunter.class.ChatMessage, repo)
|
||||
|
||||
const i = integration.octokit.paginate.iterator(integration.octokit.rest.issues.listCommentsForRepo, {
|
||||
owner: repo.owner?.login as string,
|
||||
repo: repo.name,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'asc',
|
||||
since,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
})
|
||||
try {
|
||||
for await (const data of i) {
|
||||
const comments: CommentExternalData[] = data.data as any
|
||||
this.ctx.info('retrieve comments for', {
|
||||
repo: repo.name,
|
||||
comments: comments.length,
|
||||
used: data.headers['x-ratelimit-used'],
|
||||
limit: data.headers['x-ratelimit-limit'],
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
await this.syncComments(repo, comments, derivedClient)
|
||||
this.provider.sync()
|
||||
}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
this.ctx.error(err)
|
||||
}
|
||||
integration.synchronized.add(syncKey)
|
||||
}
|
||||
}
|
||||
|
||||
async syncComments (
|
||||
repo: GithubIntegrationRepository,
|
||||
comments: CommentExternalData[],
|
||||
derivedClient: TxOperations
|
||||
): Promise<void> {
|
||||
if (repo.githubProject == null) {
|
||||
return
|
||||
}
|
||||
const syncInfo = await this.client.findAll<DocSyncInfo>(github.class.DocSyncInfo, {
|
||||
space: repo.githubProject,
|
||||
repository: repo._id,
|
||||
objectClass: chunter.class.ChatMessage,
|
||||
url: { $in: comments.map((it) => (it.url ?? '').toLowerCase()) }
|
||||
})
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
const existing = syncInfo.find((it) => it.url === comment.url.toLowerCase())
|
||||
const lastModified = new Date(comment.updated_at).getTime()
|
||||
if (existing === undefined) {
|
||||
await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject, {
|
||||
url: comment.url.toLowerCase(),
|
||||
needSync: '',
|
||||
githubNumber: 0,
|
||||
objectClass: chunter.class.ChatMessage,
|
||||
external: comment,
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
parent: comment.html_url.split('#')?.[0],
|
||||
repository: repo._id,
|
||||
lastModified
|
||||
})
|
||||
} else {
|
||||
if (!deepEqual(existing.external, comment) || existing.externalVersion !== githubExternalSyncVersion) {
|
||||
await derivedClient.diffUpdate(
|
||||
existing,
|
||||
{
|
||||
needSync: '',
|
||||
external: comment,
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
lastModified
|
||||
},
|
||||
lastModified
|
||||
)
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
this.ctx.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
import {
|
||||
GithubIssueStateReason,
|
||||
GithubPullRequestReviewState,
|
||||
GithubPullRequestState,
|
||||
GithubReviewDecisionState,
|
||||
PullRequestMergeable
|
||||
} from '@hcengineering/github'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type GithubDataType = 'SINGLE_SELECT' | 'TEXT' | 'DATE' | 'NUMBER'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubProjectV2 {
|
||||
projectV2: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
updatedAt: string
|
||||
fields: {
|
||||
edges: GithubProjectV2Field[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubProjectV2FieldOption {
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
id: string
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubProjectV2Field {
|
||||
node: {
|
||||
dataType: GithubDataType
|
||||
updatedAt: string
|
||||
|
||||
id: string
|
||||
name: string
|
||||
options?: GithubProjectV2FieldOption[]
|
||||
} & Record<string, any>
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubProjectV2ItemFieldValue {
|
||||
id: string
|
||||
// Date
|
||||
date?: string
|
||||
// Number
|
||||
number?: number
|
||||
// Single select
|
||||
color?: string
|
||||
description?: string
|
||||
optionId?: string
|
||||
// Text
|
||||
text?: string
|
||||
field: {
|
||||
id: string
|
||||
name: string
|
||||
dataType: GithubDataType
|
||||
}
|
||||
}
|
||||
|
||||
export interface GithubProjectV2Item {
|
||||
id: string
|
||||
type: 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED'
|
||||
project: {
|
||||
id: string
|
||||
number: number
|
||||
}
|
||||
fieldValues: {
|
||||
nodes: (GithubProjectV2ItemFieldValue | any)[]
|
||||
}
|
||||
}
|
||||
|
||||
export const projectV2Field = `
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
dataType
|
||||
}
|
||||
... on ProjectV2IterationField {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
updatedAt
|
||||
}
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
name
|
||||
id
|
||||
color
|
||||
description
|
||||
}
|
||||
dataType
|
||||
updatedAt
|
||||
}
|
||||
`
|
||||
|
||||
export const projectV2ItemFields = `
|
||||
... on ProjectV2ItemFieldDateValue {
|
||||
id
|
||||
date
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldNumberValue {
|
||||
id
|
||||
number
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
optionId
|
||||
field {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldTextValue {
|
||||
id
|
||||
text
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const assigneesField = `
|
||||
assignees(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
login
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
export const authorField = `
|
||||
author {
|
||||
login
|
||||
... on User {
|
||||
id
|
||||
email
|
||||
name
|
||||
}
|
||||
avatarUrl
|
||||
}
|
||||
`
|
||||
export const labelsField = `
|
||||
labels(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
export const participantsField = `
|
||||
participants(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
login
|
||||
}
|
||||
}
|
||||
`
|
||||
export const reactionsField = `
|
||||
reactions(first: 50) {
|
||||
nodes {
|
||||
content
|
||||
createdAt
|
||||
id
|
||||
user {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
login: string
|
||||
name: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const issueDetails = (stateReason: boolean): string => `
|
||||
body
|
||||
closed
|
||||
closedAt
|
||||
${authorField}
|
||||
${assigneesField}
|
||||
createdAt
|
||||
createdViaEmail
|
||||
id
|
||||
${labelsField}
|
||||
locked
|
||||
number
|
||||
${participantsField}
|
||||
state
|
||||
${stateReason ? 'stateReason' : ''}
|
||||
title
|
||||
updatedAt
|
||||
url
|
||||
${reactionsField}
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes {
|
||||
id
|
||||
type
|
||||
project {
|
||||
id
|
||||
url
|
||||
number
|
||||
}
|
||||
fieldValues(first: 50) {
|
||||
nodes {
|
||||
${projectV2ItemFields}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lastEditedAt
|
||||
publishedAt
|
||||
`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface IssueExternalData {
|
||||
closed: boolean
|
||||
closedAt?: string // Date UTCZ
|
||||
author: UserInfo
|
||||
assignees: {
|
||||
nodes: UserInfo[]
|
||||
}
|
||||
createdAt: string
|
||||
body: string
|
||||
createdViaEmail?: boolean
|
||||
id: string
|
||||
labels: {
|
||||
nodes: {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
}[]
|
||||
}
|
||||
locked: boolean
|
||||
number: number
|
||||
participants: {
|
||||
nodes: UserInfo[]
|
||||
}
|
||||
state: 'CLOSED' | 'OPEN' | 'MERGED'
|
||||
stateReason?: GithubIssueStateReason | null
|
||||
title: string
|
||||
updatedAt: string
|
||||
url: string
|
||||
reactions: {
|
||||
nodes: {
|
||||
content: string
|
||||
createdAt: string
|
||||
id: string
|
||||
user: {
|
||||
login: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
projectItems: {
|
||||
nodes: GithubProjectV2Item[]
|
||||
}
|
||||
lastEditedAt: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubCommit {
|
||||
additions: number
|
||||
authoredDate: string
|
||||
authoredByCommitter: boolean
|
||||
changedFiles: number
|
||||
commitUrl: string
|
||||
deletions: number
|
||||
id: string
|
||||
message: string
|
||||
messageBody: string
|
||||
oid: string
|
||||
pushedDate: string | null
|
||||
signature: {
|
||||
email?: string
|
||||
state: string
|
||||
}
|
||||
url: string
|
||||
committedDate: string | null
|
||||
status: {
|
||||
state: CommitStatus
|
||||
id: string
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export enum GithubPatchStatus {
|
||||
ADDED = 'ADDED',
|
||||
// The file was added. Git status 'A'.
|
||||
DELETED = 'DELETED',
|
||||
// The file was deleted. Git status 'D'.
|
||||
RENAMED = 'RENAMED',
|
||||
// The file was renamed. Git status 'R'.
|
||||
COPIED = 'COPIED',
|
||||
// The file was copied. Git status 'C'.
|
||||
MODIFIED = 'MODIFIED',
|
||||
// The file's contents were changed. Git status 'M'.
|
||||
CHANGED = 'CHANGED'
|
||||
// The file's type was changed. Git status 'T'.
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export enum CommitStatus {
|
||||
EXPECTED = 'EXPECTED',
|
||||
// Status is expected.
|
||||
ERROR = 'ERROR',
|
||||
// Status is errored.
|
||||
FAILURE = 'FAILURE',
|
||||
// Status is failing.
|
||||
PENDING = 'PENDING',
|
||||
// Status is pending.
|
||||
SUCCESS = 'SUCCESS'
|
||||
// Status is successful.
|
||||
}
|
||||
|
||||
export type PullRequestReviewState = 'PENDING' | 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'
|
||||
|
||||
export type AuthorAssociationType =
|
||||
| 'COLLABORATOR'
|
||||
| 'CONTRIBUTOR'
|
||||
| 'FIRST_TIMER'
|
||||
| 'FIRST_TIME_CONTRIBUTOR'
|
||||
| 'MANNEQUIN'
|
||||
| 'MEMBER'
|
||||
| 'NONE'
|
||||
| 'OWNER'
|
||||
|
||||
export type MinimizeReason = 'abuse' | 'off-topic' | 'outdated' | 'resolved' | 'duplicate' | 'spam'
|
||||
|
||||
export interface Review {
|
||||
id: string
|
||||
url: string
|
||||
|
||||
state: PullRequestReviewState
|
||||
author: UserInfo
|
||||
|
||||
body: string
|
||||
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
publishedAt: string | null
|
||||
lastEditedAt: string | null
|
||||
submittedAt: string | null
|
||||
|
||||
isMinimized: boolean | null
|
||||
minimizedReason: MinimizeReason
|
||||
|
||||
authorAssociation: AuthorAssociationType
|
||||
|
||||
comments: {
|
||||
totalCount: number
|
||||
nodes: {
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReviewThread {
|
||||
id: string
|
||||
line: number
|
||||
subjectType: 'LINE' | 'FILE'
|
||||
startLine: number
|
||||
isOutdated: boolean
|
||||
isResolved: boolean
|
||||
diffSide: 'LEFT' | 'RIGHT'
|
||||
isCollapsed: boolean
|
||||
originalLine: number
|
||||
originalStartLine: number | null
|
||||
path: string
|
||||
startDiffSide: 'LEFT' | 'RIGHT' | null
|
||||
resolvedBy: UserInfo | null
|
||||
comments: {
|
||||
totalCount: number
|
||||
nodes: ReviewComment[]
|
||||
}
|
||||
}
|
||||
export interface ReviewComment {
|
||||
id: string
|
||||
url: string
|
||||
body: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
publishedAt: string | null
|
||||
draftedAt: string | null
|
||||
lastEditedAt: string | null
|
||||
outdated: boolean
|
||||
includesCreatedEdit: boolean
|
||||
isMinimized: boolean
|
||||
minimizedReason: MinimizeReason
|
||||
line: number | null
|
||||
startLine: number | null
|
||||
originalLine: number | null
|
||||
originalStartLine: number | null
|
||||
diffHunk: string | null
|
||||
path: string
|
||||
replyTo: {
|
||||
url: string
|
||||
} | null
|
||||
author: UserInfo
|
||||
|
||||
pullRequestReview: {
|
||||
url: string
|
||||
state: PullRequestReviewState
|
||||
author: UserInfo
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface PullRequestExternalData extends IssueExternalData {
|
||||
isDraft: boolean
|
||||
additions: number
|
||||
deletions: number
|
||||
changedFiles: number
|
||||
commits: {
|
||||
nodes: {
|
||||
commit: GithubCommit
|
||||
}[]
|
||||
}
|
||||
headRefName: string
|
||||
headRefOid: string
|
||||
|
||||
merged: boolean
|
||||
mergedAt?: string | null
|
||||
mergeable: PullRequestMergeable
|
||||
mergedBy?: UserInfo
|
||||
state: 'OPEN' | 'CLOSED' | 'MERGED'
|
||||
|
||||
reviewDecision: 'CHANGES_REQUESTED' | 'APPROVED' | 'REVIEW_REQUIRED'
|
||||
headRef: {
|
||||
name: string
|
||||
id: string
|
||||
prefix: string
|
||||
}
|
||||
baseRef: {
|
||||
name: string
|
||||
id: string
|
||||
prefix: string
|
||||
}
|
||||
reviews: {
|
||||
totalCount: number
|
||||
nodes: Review[]
|
||||
}
|
||||
reviewThreads: {
|
||||
totalCount: number
|
||||
nodes: ReviewThread[]
|
||||
}
|
||||
|
||||
latestReviews: {
|
||||
totalCount: number
|
||||
nodes: Review[]
|
||||
}
|
||||
reviewRequests: {
|
||||
totalCount: number
|
||||
nodes: {
|
||||
requestedReviewer: UserInfo
|
||||
}[]
|
||||
}
|
||||
files: {
|
||||
totalCount: number
|
||||
nodes: {
|
||||
additions: number
|
||||
changeType: GithubPatchStatus
|
||||
deletions: number
|
||||
path: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const pullRequestCommits = `
|
||||
commits(first: 50) {
|
||||
nodes {
|
||||
commit {
|
||||
additions
|
||||
authoredDate
|
||||
authoredByCommitter
|
||||
changedFiles
|
||||
commitUrl
|
||||
deletions
|
||||
id
|
||||
message
|
||||
messageBody
|
||||
oid
|
||||
pushedDate
|
||||
signature {
|
||||
email
|
||||
state
|
||||
}
|
||||
url
|
||||
committedDate
|
||||
status {
|
||||
state
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
export const reviewDetailsNoComments = `
|
||||
state
|
||||
author {
|
||||
login
|
||||
url
|
||||
... on User {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
login
|
||||
name
|
||||
}
|
||||
}
|
||||
url
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
id
|
||||
isMinimized
|
||||
minimizedReason
|
||||
authorAssociation
|
||||
lastEditedAt
|
||||
publishedAt
|
||||
resourcePath
|
||||
submittedAt`
|
||||
|
||||
export const reviewDetails = `
|
||||
${reviewDetailsNoComments}
|
||||
comments(first: 50) {
|
||||
nodes {
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const reviewsDescr = `
|
||||
reviews(first: 50, states:[PENDING, COMMENTED, APPROVED, CHANGES_REQUESTED, DISMISSED]) {
|
||||
totalCount
|
||||
nodes {
|
||||
${reviewDetails}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const reviewCommentDetails = `
|
||||
id
|
||||
url
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
publishedAt
|
||||
draftedAt
|
||||
outdated
|
||||
lastEditedAt
|
||||
includesCreatedEdit
|
||||
isMinimized
|
||||
minimizedReason
|
||||
line
|
||||
startLine
|
||||
originalLine
|
||||
originalStartLine
|
||||
diffHunk
|
||||
path
|
||||
pullRequestReview {
|
||||
url
|
||||
state
|
||||
author {
|
||||
login
|
||||
url
|
||||
... on User {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
login
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
replyTo {
|
||||
url
|
||||
}
|
||||
author {
|
||||
avatarUrl
|
||||
login
|
||||
resourcePath
|
||||
url
|
||||
}
|
||||
`
|
||||
|
||||
export const reviewThreadDetails = `
|
||||
id
|
||||
subjectType
|
||||
line
|
||||
startLine
|
||||
isOutdated
|
||||
isResolved
|
||||
diffSide
|
||||
isCollapsed
|
||||
originalLine
|
||||
originalStartLine
|
||||
path
|
||||
startDiffSide
|
||||
resolvedBy {
|
||||
url
|
||||
login
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarUrl
|
||||
}
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
${reviewCommentDetails}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const reviewRequestsDescr = `
|
||||
reviewThreads(first: 90) {
|
||||
totalCount
|
||||
nodes {
|
||||
__typename
|
||||
${reviewThreadDetails}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const pullRequestDetails = `
|
||||
${issueDetails(false)}
|
||||
isDraft
|
||||
additions
|
||||
deletions
|
||||
changedFiles
|
||||
${pullRequestCommits}
|
||||
headRefName
|
||||
headRefOid
|
||||
merged
|
||||
mergedAt
|
||||
mergeable
|
||||
state
|
||||
reviewDecision
|
||||
headRef {
|
||||
name
|
||||
id
|
||||
prefix
|
||||
}
|
||||
baseRef {
|
||||
name
|
||||
id
|
||||
prefix
|
||||
}
|
||||
mergedBy {
|
||||
login
|
||||
url
|
||||
... on User {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
login
|
||||
name
|
||||
}
|
||||
}
|
||||
${reviewsDescr}
|
||||
${reviewRequestsDescr}
|
||||
latestReviews(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
${reviewDetailsNoComments}
|
||||
}
|
||||
}
|
||||
reviewRequests(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
requestedReviewer {
|
||||
__typename
|
||||
... on User {
|
||||
login
|
||||
avatarUrl
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files(first: 100) {
|
||||
totalCount
|
||||
nodes {
|
||||
additions
|
||||
changeType
|
||||
deletions
|
||||
path
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const projectValue = `project {
|
||||
id
|
||||
url
|
||||
number
|
||||
}`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const fieldValues = `fieldValues(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldDateValue {
|
||||
id
|
||||
date
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldNumberValue {
|
||||
id
|
||||
number
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
optionId
|
||||
field {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ProjectV2ItemFieldTextValue {
|
||||
id
|
||||
text
|
||||
field {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const supportedGithubTypes = new Set(['TEXT', 'NUMBER', 'DATA', 'SINGLE_SELECT'])
|
||||
|
||||
export function toPRState (state: PullRequestExternalData['state']): GithubPullRequestState {
|
||||
switch (state) {
|
||||
case 'OPEN':
|
||||
return GithubPullRequestState.open
|
||||
case 'CLOSED':
|
||||
return GithubPullRequestState.closed
|
||||
case 'MERGED':
|
||||
return GithubPullRequestState.merged
|
||||
}
|
||||
}
|
||||
export function toReviewState (state: PullRequestReviewState): GithubPullRequestReviewState {
|
||||
switch (state) {
|
||||
case 'PENDING':
|
||||
return GithubPullRequestReviewState.Pending
|
||||
case 'COMMENTED':
|
||||
return GithubPullRequestReviewState.Commented
|
||||
case 'APPROVED':
|
||||
return GithubPullRequestReviewState.Approved
|
||||
case 'CHANGES_REQUESTED':
|
||||
return GithubPullRequestReviewState.ChangesRequested
|
||||
case 'DISMISSED':
|
||||
return GithubPullRequestReviewState.Dismissed
|
||||
}
|
||||
}
|
||||
export function toReviewDecision (reviewDecision: PullRequestExternalData['reviewDecision']): GithubReviewDecisionState {
|
||||
switch (reviewDecision) {
|
||||
case 'APPROVED':
|
||||
return GithubReviewDecisionState.Approved
|
||||
case 'REVIEW_REQUIRED':
|
||||
return GithubReviewDecisionState.ReviewRequired
|
||||
case 'CHANGES_REQUESTED':
|
||||
return GithubReviewDecisionState.ChangesRequested
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpdatedAtReviewThread (review: ReviewThread): number {
|
||||
const value = (review.comments.nodes.map((it) => it.updatedAt).filter((it) => it != null) as string[])
|
||||
.map((it) => new Date(it).getTime())
|
||||
.reduce((prev, it) => (it > prev ? it : prev), 0)
|
||||
if (value === 0) {
|
||||
return Date.now()
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { Branding, TxOperations, WorkspaceIdWithUrl } from '@hcengineering/core'
|
||||
import { MarkupMarkType, MarkupNode, MarkupNodeType, traverseMarkupNode } from '@hcengineering/text'
|
||||
import { getPublicLink } from '@hcengineering/server-guest-resources'
|
||||
import { Issue } from '@hcengineering/tracker'
|
||||
|
||||
const githubLinkText = process.env.LINK_TEXT ?? 'Huly®:'
|
||||
|
||||
const githubLinkTextOld = 'View in Huly'
|
||||
|
||||
export function hasHulyLinkText (text: string): boolean {
|
||||
return text.includes(githubLinkText) || text.includes(githubLinkTextOld)
|
||||
}
|
||||
|
||||
export function hasHulyLink (href: string, guestLink: string): boolean {
|
||||
return href.includes(guestLink)
|
||||
}
|
||||
|
||||
export async function stripGuestLink (markdown: MarkupNode): Promise<void> {
|
||||
const toRemove: MarkupNode[] = []
|
||||
|
||||
traverseMarkupNode(markdown, (node) => {
|
||||
if (node.content === undefined) {
|
||||
return
|
||||
}
|
||||
const oldLength = node.content.length
|
||||
node.content = node.content.filter((it) => it.type !== MarkupNodeType.subLink)
|
||||
|
||||
// sub is an inline node hence tiptap wraps it with a paragraph
|
||||
// so we need to remove the parent paragraph node if it is empty
|
||||
if (node.content.length !== oldLength && node.type === MarkupNodeType.paragraph) {
|
||||
toRemove.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// traverse nodes once again and remove empty parent node
|
||||
traverseMarkupNode(markdown, (node) => {
|
||||
if (node.content === undefined) {
|
||||
return
|
||||
}
|
||||
node.content = node.content.filter((it) => !toRemove.includes(it))
|
||||
})
|
||||
}
|
||||
export async function appendGuestLink (
|
||||
client: TxOperations,
|
||||
doc: Issue,
|
||||
markdown: MarkupNode,
|
||||
workspace: WorkspaceIdWithUrl,
|
||||
branding: Branding | null
|
||||
): Promise<void> {
|
||||
const publicLink = await getPublicLink(doc, client, workspace, false, branding)
|
||||
await stripGuestLink(markdown)
|
||||
appendGuestLinkToModel(markdown, publicLink, doc.identifier)
|
||||
}
|
||||
|
||||
export function appendGuestLinkToModel (markdown: MarkupNode, publicLink: string, identifier: string): void {
|
||||
markdown.content = [
|
||||
...(markdown.content ?? []),
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.subLink,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: githubLinkText.trim() + ' <b>' + identifier + '</b>',
|
||||
marks: [{ type: MarkupMarkType.link, attrs: { href: publicLink, _target: '_blank' } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
//
|
||||
|
||||
import core, { Doc, DocData, DocumentUpdate, MeasureContext, TxOperations, generateId } from '@hcengineering/core'
|
||||
import { Endpoints } from '@octokit/types'
|
||||
import { Repository, RepositoryEvent } from '@octokit/webhooks-types'
|
||||
import github, { DocSyncInfo, GithubIntegrationRepository, GithubProject } from '@hcengineering/github'
|
||||
import { App } from 'octokit'
|
||||
import { DocSyncManager, ExternalSyncField, IntegrationContainer, IntegrationManager } from '../types'
|
||||
import { collectUpdate } from './utils'
|
||||
|
||||
const syncReposKey = 'repo_sync'
|
||||
|
||||
export class RepositorySyncMapper implements DocSyncManager {
|
||||
constructor (
|
||||
private readonly ctx: MeasureContext,
|
||||
private readonly client: TxOperations,
|
||||
private readonly app: App
|
||||
) {}
|
||||
|
||||
externalDerivedSync = false
|
||||
|
||||
provider!: IntegrationManager
|
||||
|
||||
// Initialize the mapper.
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
// Perform synchronization of document with external source.
|
||||
async sync (existing: Doc | undefined, info: DocSyncInfo): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
return {}
|
||||
}
|
||||
|
||||
async reloadRepositories (integration: IntegrationContainer): Promise<void> {
|
||||
integration.synchronized.delete(syncReposKey)
|
||||
}
|
||||
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
||||
const event = evt as RepositoryEvent
|
||||
|
||||
const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System
|
||||
switch (event.action) {
|
||||
case 'created': {
|
||||
await this.client.addCollection(
|
||||
github.class.GithubIntegrationRepository,
|
||||
integration.integration.space,
|
||||
integration.integration._id,
|
||||
integration.integration._class,
|
||||
'repositories',
|
||||
{
|
||||
...this.getRData(event.repository),
|
||||
name: event.repository.name,
|
||||
repositoryId: event.repository.id,
|
||||
enabled: true
|
||||
},
|
||||
generateId(),
|
||||
Date.now(),
|
||||
account
|
||||
)
|
||||
this.ctx.info('Creating repository info document...', {
|
||||
url: event.repository.url,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'renamed': {
|
||||
const githubRepo = await this.client.findOne(github.class.GithubIntegrationRepository, {
|
||||
repositoryId: event.repository.id
|
||||
})
|
||||
if (githubRepo !== undefined) {
|
||||
await this.client.update(
|
||||
githubRepo,
|
||||
{
|
||||
name: event.repository.name
|
||||
},
|
||||
false,
|
||||
Date.now(),
|
||||
account
|
||||
)
|
||||
githubRepo.name = event.repository.name
|
||||
const allProjects = await this.client.findAll(github.mixin.GithubProject, { repositories: githubRepo?._id })
|
||||
for (const prj of allProjects) {
|
||||
// We need to force sync
|
||||
await this.handleRepoRename(integration, prj, githubRepo)
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'deleted':
|
||||
case 'transferred': {
|
||||
// TODO: Add remove of component
|
||||
const githubRepo = await this.client.findOne(github.class.GithubIntegrationRepository, {
|
||||
integration: integration.integration._id,
|
||||
name: event.repository.name
|
||||
})
|
||||
if (githubRepo !== undefined) {
|
||||
await this.client.update(
|
||||
githubRepo,
|
||||
{
|
||||
enabled: true,
|
||||
deleted: true
|
||||
},
|
||||
false,
|
||||
Date.now(),
|
||||
account
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean
|
||||
): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
getRData (
|
||||
repository: Repository | Endpoints['GET /installation/repositories']['response']['data']['repositories'][0]
|
||||
): Omit<DocData<GithubIntegrationRepository>, 'name' | 'repositoryId' | 'deleted' | 'githubProjects' | 'enabled'> {
|
||||
return {
|
||||
nodeId: repository.node_id,
|
||||
url: repository.url,
|
||||
htmlURL: repository.html_url,
|
||||
owner: {
|
||||
id: repository.owner.node_id,
|
||||
login: repository.owner.login,
|
||||
avatarUrl: repository.owner.avatar_url,
|
||||
email: repository.owner.email ?? undefined,
|
||||
name: repository.owner.name ?? undefined
|
||||
},
|
||||
description: repository.description ?? undefined,
|
||||
fork: repository.fork,
|
||||
forks: repository.forks,
|
||||
private: repository.private,
|
||||
stargazers: repository.stargazers_count,
|
||||
|
||||
hasIssues: repository.has_issues,
|
||||
hasProjects: repository.has_projects,
|
||||
hasDownloads: repository.has_downloads,
|
||||
hasPages: repository.has_pages,
|
||||
hasWiki: repository.has_wiki,
|
||||
hasDiscussions: repository.has_discussions ?? false,
|
||||
|
||||
openIssues: repository.open_issues,
|
||||
watchers: repository.watchers_count,
|
||||
archived: repository.archived,
|
||||
size: repository.size,
|
||||
language: repository.language ?? undefined,
|
||||
resourcePath: repository.full_name,
|
||||
|
||||
visibility: repository.visibility,
|
||||
updatedAt: new Date(repository.updated_at ?? repository.created_at ?? Date.now()).getTime()
|
||||
}
|
||||
}
|
||||
|
||||
async externalSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
kind: ExternalSyncField,
|
||||
syncDocs: DocSyncInfo[],
|
||||
repo: GithubIntegrationRepository,
|
||||
prj: GithubProject
|
||||
): Promise<void> {}
|
||||
|
||||
repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {}
|
||||
|
||||
async externalFullSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
projects: GithubProject[],
|
||||
repositories: GithubIntegrationRepository[]
|
||||
): Promise<void> {
|
||||
const inst = integration.octokit
|
||||
if (inst === undefined || integration.octokit === undefined) {
|
||||
this.ctx.info('no installation found', { workspace: this.provider.getWorkspaceId().name })
|
||||
return
|
||||
}
|
||||
|
||||
if (integration.synchronized.has(syncReposKey)) {
|
||||
return
|
||||
}
|
||||
this.ctx.info('Checking github installation repositories...', {
|
||||
installationId: integration.installationId,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
const iterable = this.app.eachRepository.iterator({ installationId: integration.installationId })
|
||||
|
||||
// Need to find all repositories, not only active, so passed repositories are not work.
|
||||
const allRepositories = (
|
||||
await this.provider.liveQuery.queryFind(github.class.GithubIntegrationRepository, {})
|
||||
).filter((it) => it.attachedTo === integration.integration._id)
|
||||
|
||||
let allRepos: GithubIntegrationRepository[] = [...allRepositories]
|
||||
|
||||
for await (const { repository } of iterable) {
|
||||
const integrationRepo: GithubIntegrationRepository | undefined = allRepos.find(
|
||||
(it) => it.repositoryId === repository.id
|
||||
)
|
||||
|
||||
const rdata = this.getRData(repository)
|
||||
if (integrationRepo === undefined) {
|
||||
// No integration repository found, we need to push one.
|
||||
await this.client.addCollection(
|
||||
github.class.GithubIntegrationRepository,
|
||||
integration.integration.space,
|
||||
integration.integration._id,
|
||||
integration.integration._class,
|
||||
'repositories',
|
||||
{
|
||||
...rdata,
|
||||
name: repository.name,
|
||||
repositoryId: repository.id,
|
||||
enabled: true,
|
||||
deleted: false
|
||||
},
|
||||
undefined, // id
|
||||
Date.now(),
|
||||
integration.integration.createdBy
|
||||
)
|
||||
this.ctx.info('Creating repository info document...', {
|
||||
url: repository.url,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
} else {
|
||||
allRepos = allRepos.filter((it) => it._id !== integrationRepo._id)
|
||||
const diff = collectUpdate(
|
||||
integrationRepo,
|
||||
{
|
||||
name: repository.name,
|
||||
...rdata
|
||||
},
|
||||
['name', ...Object.keys(rdata)]
|
||||
)
|
||||
if (Object.keys(diff).length > 0) {
|
||||
this.ctx.info('processing repository diff update...', {
|
||||
repository: repository.name,
|
||||
...diff,
|
||||
workspace: this.provider.getWorkspaceId().name
|
||||
})
|
||||
await this.client.diffUpdate(
|
||||
integrationRepo,
|
||||
{
|
||||
name: repository.name,
|
||||
...rdata
|
||||
},
|
||||
new Date().getTime(),
|
||||
integration.integration.createdBy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ok we have repos removed from integration, we need to delete them.
|
||||
for (const repo of allRepos) {
|
||||
await this.client.remove(repo)
|
||||
const prj = projects.find((it) => it._id === repo.githubProject)
|
||||
if (prj !== undefined) {
|
||||
await this.client.update(prj, {
|
||||
$pull: { repositories: repo._id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// We need to delete and disconnect missing repositories.
|
||||
|
||||
integration.synchronized.add(syncReposKey)
|
||||
}
|
||||
|
||||
// Perform a synchronization of a single repository.
|
||||
async handleRepoRename (
|
||||
integration: IntegrationContainer,
|
||||
prj: GithubProject,
|
||||
repo: GithubIntegrationRepository
|
||||
): Promise<void> {
|
||||
// We need to update urls for all sync documents belong to this repository.
|
||||
|
||||
const derivedClient = new TxOperations(this.client, core.account.System, true)
|
||||
const processingId = generateId()
|
||||
|
||||
// Wait previous sync to finish
|
||||
await integration.syncLock.get(prj._id)
|
||||
|
||||
/**
|
||||
Variants:
|
||||
"https://api.github.com/repos/hcengineering/anticrm/issues/comments/1679316918"
|
||||
"https://github.com/hcengineering/uberflow/pull/195"
|
||||
* */
|
||||
this.ctx.info('handle repository rename', { repo, workspace: this.provider.getWorkspaceId().name })
|
||||
const update = async (): Promise<void> => {
|
||||
while (true) {
|
||||
const docs = await this.client.findAll(
|
||||
github.class.DocSyncInfo,
|
||||
{ _class: github.class.DocSyncInfo, repository: repo._id, processingId: { $ne: processingId } },
|
||||
{ limit: 1000 }
|
||||
)
|
||||
const ops = derivedClient.apply(repo._id)
|
||||
if (docs.length === 0) {
|
||||
break
|
||||
}
|
||||
for (const d of docs) {
|
||||
const ul = d.url.split('/')
|
||||
if (ul[2] === 'api.github.com') {
|
||||
ul[5] = repo.name
|
||||
} else {
|
||||
ul[4] = repo.name
|
||||
}
|
||||
// We need to mark sync is required, to perform github
|
||||
await ops.diffUpdate(d, { url: ul.join('/'), processingId, needSync: '', externalVersion: '' })
|
||||
}
|
||||
await ops.commit()
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
const p = update()
|
||||
integration.syncLock.set(prj._id, p)
|
||||
await p
|
||||
integration.syncLock.delete(prj._id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
//
|
||||
// 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<GithubReviewComment>
|
||||
|
||||
export class ReviewCommentSyncManager implements DocSyncManager {
|
||||
provider!: IntegrationManager
|
||||
|
||||
createCommentPromise: Promise<DocumentUpdate<DocSyncInfo>> | undefined
|
||||
|
||||
externalDerivedSync = false
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly client: TxOperations,
|
||||
readonly lq: LiveQuery
|
||||
) {}
|
||||
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
eventSync = new Map<string, Promise<void>>()
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
||||
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<boolean> {
|
||||
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<Account>,
|
||||
id: string,
|
||||
derivedClient: TxOperations,
|
||||
parent?: DocSyncInfo
|
||||
): Promise<void> {
|
||||
const okit = (await this.provider.getOctokit(account as Ref<PersonAccount>)) ?? 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<void> {
|
||||
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, {
|
||||
url: (event.comment.html_url ?? '').toLowerCase()
|
||||
})
|
||||
if (reviewData !== undefined) {
|
||||
await derivedClient.update<DocSyncInfo>(
|
||||
reviewData,
|
||||
{
|
||||
deleted: true,
|
||||
needSync: ''
|
||||
},
|
||||
false,
|
||||
Date.now(),
|
||||
account
|
||||
)
|
||||
this.provider.sync()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'edited': {
|
||||
const reviewData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (event.comment.html_url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
if (reviewData !== undefined) {
|
||||
const reviewObj: GithubReviewComment | undefined = await this.client.findOne<GithubReviewComment>(
|
||||
reviewData.objectClass,
|
||||
{
|
||||
_id: reviewData._id as unknown as Ref<GithubReviewComment>
|
||||
}
|
||||
)
|
||||
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<void> {
|
||||
const reviewData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (createdEvent.comment.html_url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
if (reviewData === undefined) {
|
||||
await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref<GithubProject>, {
|
||||
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,
|
||||
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<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
const container = await this.provider.getContainer(info.space)
|
||||
if (container?.container === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (parent === undefined) {
|
||||
return { needSync: '' }
|
||||
}
|
||||
if (info.external === undefined) {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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, {
|
||||
url: reviewComment.replyTo?.url?.toLowerCase()
|
||||
})
|
||||
if (rthread !== undefined) {
|
||||
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<Account>,
|
||||
derivedClient: TxOperations
|
||||
): Promise<void> {
|
||||
const repository = container.repository.find((it) => it._id === info.repository)
|
||||
if (repository === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingReview = existing as GithubReviewComment
|
||||
|
||||
const previousData: ReviewCommentData = info.current ?? ({} as unknown as ReviewCommentData)
|
||||
|
||||
const update = collectUpdate<GithubReviewComment>(previousData, reviewCommentData, Object.keys(reviewCommentData))
|
||||
|
||||
const platformUpdate = collectUpdate<GithubReviewComment>(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<PersonAccount>)) ?? 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<Account>
|
||||
): Promise<void> {
|
||||
const _id: Ref<GithubReviewComment> = info._id as unknown as Ref<GithubReviewComment>
|
||||
const value: AttachedData<GithubReviewComment> = {
|
||||
...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<DocumentUpdate<DocSyncInfo>> {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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<PersonAccount>)) ?? 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<DocSyncInfo> = {
|
||||
url: reviewExternal.url.toLowerCase(),
|
||||
external: reviewExternal,
|
||||
current: existing,
|
||||
repository: repo._id,
|
||||
needSync: githubSyncVersion,
|
||||
externalVersion: githubExternalSyncVersion
|
||||
}
|
||||
// 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<void> {
|
||||
// No need to perform external sync for reviews, so let's update marks
|
||||
const tx = derivedClient.apply('reviews_github')
|
||||
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<void> {
|
||||
// No external sync for reviews, they are done in pull requests.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import { PersonAccount } from '@hcengineering/contact'
|
||||
import core, {
|
||||
Account,
|
||||
AttachedData,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
MeasureContext,
|
||||
Ref,
|
||||
TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import { EmptyMarkup } from '@hcengineering/text'
|
||||
import { LiveQuery } from '@hcengineering/query'
|
||||
import github, {
|
||||
DocSyncInfo,
|
||||
GithubIntegrationRepository,
|
||||
GithubProject,
|
||||
GithubReviewThread
|
||||
} from '@hcengineering/github'
|
||||
import {
|
||||
ContainerFocus,
|
||||
DocSyncManager,
|
||||
ExternalSyncField,
|
||||
IntegrationContainer,
|
||||
IntegrationManager,
|
||||
githubDerivedSyncVersion,
|
||||
githubExternalSyncVersion,
|
||||
githubSyncVersion
|
||||
} from '../types'
|
||||
import {
|
||||
PullRequestExternalData,
|
||||
ReviewThread as ReviewThreadExternalData,
|
||||
getUpdatedAtReviewThread,
|
||||
reviewThreadDetails
|
||||
} from './githubTypes'
|
||||
import { collectUpdate, deleteObjects, errorToObj, isGHWriteAllowed, syncDerivedDocuments } from './utils'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { PullRequestReviewThreadEvent } from '@octokit/webhooks-types'
|
||||
import config from '../config'
|
||||
import { syncConfig } from './syncConfig'
|
||||
|
||||
export type ReviewThreadData = Pick<
|
||||
GithubReviewThread,
|
||||
| 'threadId'
|
||||
| 'line'
|
||||
| 'diffSide'
|
||||
| 'startLine'
|
||||
| 'isCollapsed'
|
||||
| 'isPinned'
|
||||
| 'isResolved'
|
||||
| 'isOutdated'
|
||||
| 'path'
|
||||
| 'originalLine'
|
||||
| 'originalStartLine'
|
||||
| 'resolvedBy'
|
||||
| 'startDiffSide'
|
||||
>
|
||||
|
||||
export class ReviewThreadSyncManager implements DocSyncManager {
|
||||
provider!: IntegrationManager
|
||||
|
||||
createCommentPromise: Promise<DocumentUpdate<DocSyncInfo>> | undefined
|
||||
|
||||
externalDerivedSync = true
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly client: TxOperations,
|
||||
readonly lq: LiveQuery
|
||||
) {}
|
||||
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
eventSync = new Map<string, Promise<void>>()
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
||||
await this.createCommentPromise
|
||||
const event = evt as PullRequestReviewThreadEvent
|
||||
|
||||
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('reviewThreads:handleEvent', { event, 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.thread.node_id)
|
||||
const promise = this.processEvent(event, derivedClient, repository, integration)
|
||||
this.eventSync.set(event.thread.node_id, promise)
|
||||
await promise
|
||||
this.eventSync.delete(event.thread.node_id)
|
||||
}
|
||||
|
||||
async handleDelete (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean
|
||||
): Promise<boolean> {
|
||||
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)
|
||||
} 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<Account>, id: string): Promise<void> {
|
||||
// Not supported
|
||||
}
|
||||
|
||||
private async processEvent (
|
||||
event: PullRequestReviewThreadEvent,
|
||||
derivedClient: TxOperations,
|
||||
repo: GithubIntegrationRepository,
|
||||
integration: IntegrationContainer
|
||||
): Promise<void> {
|
||||
const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System
|
||||
|
||||
let externalData: ReviewThreadExternalData
|
||||
try {
|
||||
const response: any = await integration.octokit?.graphql(
|
||||
`
|
||||
query listReview($reviewID: ID!) {
|
||||
node(id: $reviewID) {
|
||||
... on PullRequestReviewThread {
|
||||
${reviewThreadDetails}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
reviewID: event.thread.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 'resolved':
|
||||
case 'unresolved': {
|
||||
const isResolved = event.action === 'resolved'
|
||||
const reviewData = await this.client.findOne(github.class.DocSyncInfo, { url: event.thread.node_id })
|
||||
|
||||
if (reviewData !== undefined) {
|
||||
const reviewObj: GithubReviewThread | undefined = await this.client.findOne<GithubReviewThread>(
|
||||
reviewData.objectClass,
|
||||
{
|
||||
_id: reviewData._id as unknown as Ref<GithubReviewThread>
|
||||
}
|
||||
)
|
||||
if (reviewObj !== undefined) {
|
||||
const lastModified = Date.now()
|
||||
await derivedClient.diffUpdate(
|
||||
reviewData,
|
||||
{
|
||||
external: externalData,
|
||||
current: { ...reviewData.current, isResolved },
|
||||
needSync: githubSyncVersion,
|
||||
lastModified
|
||||
},
|
||||
lastModified
|
||||
)
|
||||
await this.client.update(
|
||||
reviewObj,
|
||||
{
|
||||
isResolved
|
||||
},
|
||||
false,
|
||||
lastModified,
|
||||
account
|
||||
)
|
||||
|
||||
// We need to trigger PR external update, to properly handle todos.
|
||||
}
|
||||
|
||||
const reviewPR = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (reviewData.parent ?? '').toLowerCase()
|
||||
})
|
||||
if (reviewPR !== undefined) {
|
||||
await derivedClient.update(reviewPR, {
|
||||
externalVersion: ''
|
||||
})
|
||||
}
|
||||
this.provider.sync()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sync (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo | undefined,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
const container = await this.provider.getContainer(info.space)
|
||||
if (container?.container === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (parent === undefined) {
|
||||
return { needSync: '' }
|
||||
}
|
||||
if (info.external === undefined) {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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.createGithubReviewThread(container, existing, info, parent, derivedClient)
|
||||
return await this.createCommentPromise
|
||||
}
|
||||
const review = info.external as ReviewThreadExternalData
|
||||
|
||||
// Use first comment as author, since github doesn't provide one.
|
||||
const account =
|
||||
existing?.modifiedBy ??
|
||||
(await this.provider.getAccount(review.comments.nodes[0].author ?? null))?._id ??
|
||||
core.account.System
|
||||
|
||||
const messageData: ReviewThreadData = {
|
||||
threadId: review.id,
|
||||
diffSide: review.diffSide,
|
||||
isCollapsed: review.isCollapsed,
|
||||
isOutdated: review.isOutdated,
|
||||
isResolved: review.isResolved,
|
||||
line: review.line,
|
||||
startLine: review.startLine,
|
||||
originalLine: review.originalLine,
|
||||
originalStartLine: review.originalStartLine,
|
||||
path: review.path,
|
||||
resolvedBy: (await this.provider.getAccount(review.resolvedBy))?._id ?? core.account.System,
|
||||
startDiffSide: review.startDiffSide
|
||||
}
|
||||
if (existing === undefined) {
|
||||
try {
|
||||
await this.createReviewThread(info, messageData, parent, review, 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, review, account, derivedClient)
|
||||
}
|
||||
return { current: messageData, needSync: githubSyncVersion }
|
||||
}
|
||||
|
||||
private async handleDiffUpdate (
|
||||
existing: Doc,
|
||||
info: DocSyncInfo,
|
||||
reviewData: ReviewThreadData,
|
||||
container: ContainerFocus,
|
||||
parent: DocSyncInfo,
|
||||
review: ReviewThreadExternalData,
|
||||
account: Ref<Account>,
|
||||
derivedClient: TxOperations
|
||||
): Promise<void> {
|
||||
const repository = container.repository.find((it) => it._id === info.repository)
|
||||
if (repository === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingReview = existing as GithubReviewThread
|
||||
|
||||
const previousData: ReviewThreadData = info.current ?? ({} as unknown as ReviewThreadData)
|
||||
|
||||
const update = collectUpdate<GithubReviewThread>(previousData, reviewData, Object.keys(reviewData))
|
||||
|
||||
const platformUpdate = collectUpdate<GithubReviewThread>(previousData, existing, Object.keys(reviewData))
|
||||
|
||||
// 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) {
|
||||
// Check and update external
|
||||
if (platformUpdate.isResolved !== undefined) {
|
||||
const okit = (await this.provider.getOctokit(account as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
const q = `mutation updateReviewThread($threadID: ID!) {
|
||||
${platformUpdate.isResolved ? 'resolveReviewThread' : 'unresolveReviewThread'} (
|
||||
input: {
|
||||
threadId: $threadID
|
||||
}) {
|
||||
thread {
|
||||
id
|
||||
isResolved
|
||||
}
|
||||
}
|
||||
}`
|
||||
try {
|
||||
if (isGHWriteAllowed()) {
|
||||
await okit?.graphql(q, {
|
||||
threadID: review.id
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
update.isResolved = !platformUpdate.isResolved
|
||||
platformUpdate.isResolved = !platformUpdate.isResolved
|
||||
this.ctx.error('Error', { err })
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
await derivedClient.update(info, { external: { ...info.external, isResolved: platformUpdate.isResolved } })
|
||||
}
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await this.client.update(existing, update, false, getUpdatedAtReviewThread(review), account)
|
||||
}
|
||||
}
|
||||
|
||||
private async createReviewThread (
|
||||
info: DocSyncInfo,
|
||||
messageData: ReviewThreadData,
|
||||
parent: DocSyncInfo,
|
||||
review: ReviewThreadExternalData,
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const _id: Ref<GithubReviewThread> = info._id as unknown as Ref<GithubReviewThread>
|
||||
const value: AttachedData<GithubReviewThread> = {
|
||||
...messageData
|
||||
}
|
||||
await this.client.addCollection(
|
||||
github.class.GithubReviewThread,
|
||||
info.space,
|
||||
parent._id,
|
||||
parent.objectClass,
|
||||
'activity',
|
||||
value,
|
||||
_id,
|
||||
new Date(review.comments.nodes[0].createdAt ?? Date.now()).getTime(),
|
||||
account
|
||||
)
|
||||
}
|
||||
|
||||
async createGithubReviewThread (
|
||||
container: ContainerFocus,
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo>> {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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 GithubReviewThread
|
||||
const okit =
|
||||
(await this.provider.getOctokit(existingReview.modifiedBy as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
|
||||
// No external version yet, create it.
|
||||
// Will be added into pending state.
|
||||
try {
|
||||
// Will be created in pending state.
|
||||
const q = `mutation addPullRequestReviewThread($prID: ID!, $body: String!) {
|
||||
addPullRequestReviewThread(input:{
|
||||
pullRequestId: $prID,
|
||||
path: "${existingReview.path}"
|
||||
body: $body,
|
||||
line: ${existingReview.line},
|
||||
side: LEFT,
|
||||
startSide: LEFT,
|
||||
}) {
|
||||
pullRequestReview {
|
||||
${reviewThreadDetails}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
if (isGHWriteAllowed()) {
|
||||
const response:
|
||||
| {
|
||||
addPullRequestReviewThread: {
|
||||
thread: ReviewThreadExternalData
|
||||
}
|
||||
}
|
||||
| undefined = await okit?.graphql(q, {
|
||||
prID: (parent.external as PullRequestExternalData).id,
|
||||
body: EmptyMarkup // TODO: Need to replace with first comment on comment sync.
|
||||
})
|
||||
|
||||
const reviewExternal = response?.addPullRequestReviewThread?.thread
|
||||
|
||||
if (reviewExternal !== undefined) {
|
||||
const upd: DocumentUpdate<DocSyncInfo> = {
|
||||
url: reviewExternal.id,
|
||||
external: reviewExternal,
|
||||
current: existing,
|
||||
repository: repo._id,
|
||||
version: githubSyncVersion,
|
||||
externalVersion: githubExternalSyncVersion
|
||||
}
|
||||
// We need to update in current promise, to prevent event changes.
|
||||
await derivedClient.update(info, upd)
|
||||
}
|
||||
}
|
||||
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[],
|
||||
repo: GithubIntegrationRepository,
|
||||
prj: GithubProject
|
||||
): Promise<void> {
|
||||
if (kind === 'externalVersion') {
|
||||
// No need to perform external sync for review threads, so let's update marks
|
||||
const tx = derivedClient.apply('review_threads_github')
|
||||
for (const d of syncDocs) {
|
||||
await tx.update(d, { externalVersion: githubExternalSyncVersion })
|
||||
}
|
||||
await tx.commit()
|
||||
this.provider.sync()
|
||||
} else if (kind === 'derivedVersion') {
|
||||
// We need to create comments.
|
||||
// Find a pull request parents
|
||||
|
||||
const allParents = syncDocs
|
||||
.map((it) => (it.parent ?? '').toLowerCase())
|
||||
.filter((it, idx, arr) => it != null && arr.indexOf(it) === idx)
|
||||
const parents = await derivedClient.findAll(github.class.DocSyncInfo, {
|
||||
url: {
|
||||
$in: allParents
|
||||
}
|
||||
})
|
||||
|
||||
for (const d of syncDocs) {
|
||||
const ext = d.external as ReviewThreadExternalData
|
||||
if (ext == null) {
|
||||
continue
|
||||
}
|
||||
if (ext.comments.nodes.length < ext.comments.totalCount) {
|
||||
// TODO: We need to fetch missing items.
|
||||
}
|
||||
|
||||
const prParent = parents.find((it) => it.url === d.parent?.toLowerCase())
|
||||
if (prParent === undefined) {
|
||||
continue
|
||||
}
|
||||
await syncDerivedDocuments<ReviewThreadExternalData & { url: string }>(
|
||||
derivedClient,
|
||||
prParent,
|
||||
{ ...ext, url: (d.parent ?? '').toLowerCase() }, // Parent is Pull request.
|
||||
prj,
|
||||
repo,
|
||||
github.class.GithubReviewComment,
|
||||
{
|
||||
reviewThreadId: ext.id
|
||||
},
|
||||
(ext) => ext.comments.nodes,
|
||||
{ reviewThreadId: ext.id }
|
||||
)
|
||||
}
|
||||
const tx = derivedClient.apply('reviewThread_github')
|
||||
for (const d of syncDocs) {
|
||||
await tx.update(d, { derivedVersion: githubDerivedSyncVersion })
|
||||
}
|
||||
await tx.commit()
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
|
||||
repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {}
|
||||
|
||||
async externalFullSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
projects: GithubProject[],
|
||||
repositories: GithubIntegrationRepository[]
|
||||
): Promise<void> {
|
||||
// No external sync for reviews, they are done in pull requests.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
import { PersonAccount } from '@hcengineering/contact'
|
||||
import core, {
|
||||
Account,
|
||||
AttachedData,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
MeasureContext,
|
||||
Ref,
|
||||
TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import { LiveQuery } from '@hcengineering/query'
|
||||
import github, {
|
||||
DocSyncInfo,
|
||||
GithubIntegrationRepository,
|
||||
GithubProject,
|
||||
GithubPullRequestReviewState,
|
||||
GithubReview
|
||||
} from '@hcengineering/github'
|
||||
import {
|
||||
ContainerFocus,
|
||||
DocSyncManager,
|
||||
ExternalSyncField,
|
||||
IntegrationContainer,
|
||||
IntegrationManager,
|
||||
githubExternalSyncVersion,
|
||||
githubSyncVersion
|
||||
} from '../types'
|
||||
import { PullRequestExternalData, Review as ReviewExternalData, reviewDetails, toReviewState } from './githubTypes'
|
||||
import { collectUpdate, deleteObjects, errorToObj, isGHWriteAllowed } from './utils'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { PullRequestReviewEvent, PullRequestReviewSubmittedEvent } from '@octokit/webhooks-types'
|
||||
import config from '../config'
|
||||
import { syncConfig } from './syncConfig'
|
||||
|
||||
export type ReviewData = Pick<GithubReview, 'body' | 'state' | 'comments'>
|
||||
|
||||
export class ReviewSyncManager implements DocSyncManager {
|
||||
provider!: IntegrationManager
|
||||
|
||||
createCommentPromise: Promise<DocumentUpdate<DocSyncInfo>> | undefined
|
||||
|
||||
externalDerivedSync = false
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly client: TxOperations,
|
||||
readonly lq: LiveQuery
|
||||
) {}
|
||||
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
eventSync = new Map<string, Promise<void>>()
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
||||
await this.createCommentPromise
|
||||
const event = evt as PullRequestReviewEvent
|
||||
|
||||
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('reviews:handleEvent', { event, 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.review.html_url)
|
||||
const promise = this.processEvent(event, derivedClient, repository, integration)
|
||||
this.eventSync.set(event.review.html_url, promise)
|
||||
await promise
|
||||
this.eventSync.delete(event.review.html_url)
|
||||
}
|
||||
|
||||
async handleDelete (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean
|
||||
): Promise<boolean> {
|
||||
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)
|
||||
} 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<Account>, id: string): Promise<void> {
|
||||
const okit = (await this.provider.getOctokit(account as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
const q = `mutation deleteReview($reviewID: ID!) {
|
||||
deletePullRequestReview(input: {
|
||||
pullRequestReviewId: $reviewID
|
||||
}) {
|
||||
pullRequestReview {
|
||||
url
|
||||
}
|
||||
}
|
||||
}`
|
||||
if (isGHWriteAllowed()) {
|
||||
await okit?.graphql(q, {
|
||||
reviewID: id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async processEvent (
|
||||
event: PullRequestReviewEvent,
|
||||
derivedClient: TxOperations,
|
||||
repo: GithubIntegrationRepository,
|
||||
integration: IntegrationContainer
|
||||
): Promise<void> {
|
||||
const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System
|
||||
|
||||
let externalData: ReviewExternalData
|
||||
try {
|
||||
const response: any = await integration.octokit?.graphql(
|
||||
`
|
||||
query listReview($reviewID: ID!) {
|
||||
node(id: $reviewID) {
|
||||
... on PullRequestReview {
|
||||
${reviewDetails}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
reviewID: event.review.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 'submitted': {
|
||||
await this.createSyncData(event, derivedClient, repo, externalData)
|
||||
|
||||
const parentDoc = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (event.pull_request.html_url ?? '').toLowerCase()
|
||||
})
|
||||
if (parentDoc !== undefined) {
|
||||
await derivedClient.update<DocSyncInfo>(parentDoc, {
|
||||
externalVersion: '',
|
||||
derivedVersion: ''
|
||||
})
|
||||
this.provider.sync()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'dismissed': {
|
||||
const reviewData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (event.review.html_url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
if (reviewData !== undefined) {
|
||||
const reviewObj: GithubReview | undefined = await this.client.findOne<GithubReview>(reviewData.objectClass, {
|
||||
_id: reviewData._id as unknown as Ref<GithubReview>
|
||||
})
|
||||
if (reviewObj !== undefined) {
|
||||
const lastModified = Date.now()
|
||||
await derivedClient.diffUpdate(
|
||||
reviewData,
|
||||
{
|
||||
external: externalData,
|
||||
current: { ...reviewData.current, state: GithubPullRequestReviewState.Dismissed },
|
||||
needSync: githubSyncVersion,
|
||||
lastModified
|
||||
},
|
||||
lastModified
|
||||
)
|
||||
await this.client.update(
|
||||
reviewObj,
|
||||
{
|
||||
state: GithubPullRequestReviewState.Dismissed
|
||||
},
|
||||
false,
|
||||
lastModified,
|
||||
account
|
||||
)
|
||||
this.provider.sync()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createSyncData (
|
||||
createdEvent: PullRequestReviewSubmittedEvent,
|
||||
derivedClient: TxOperations,
|
||||
repo: GithubIntegrationRepository,
|
||||
externalData: ReviewExternalData
|
||||
): Promise<void> {
|
||||
const reviewData = await this.client.findOne(github.class.DocSyncInfo, {
|
||||
url: (createdEvent.review.html_url ?? '').toLowerCase()
|
||||
})
|
||||
|
||||
if (reviewData === undefined) {
|
||||
await derivedClient.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref<GithubProject>, {
|
||||
url: createdEvent.review.html_url.toLowerCase(),
|
||||
needSync: '',
|
||||
githubNumber: 0,
|
||||
repository: repo._id,
|
||||
objectClass: github.class.GithubReview,
|
||||
external: externalData,
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
derivedVersion: '',
|
||||
parent: createdEvent.pull_request.html_url,
|
||||
lastModified: new Date(createdEvent.review.submitted_at ?? Date.now()).getTime()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async sync (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo | undefined,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
const container = await this.provider.getContainer(info.space)
|
||||
if (container?.container === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (parent === undefined) {
|
||||
return { needSync: '' }
|
||||
}
|
||||
if (info.external === undefined) {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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.createGithubReview(container, existing, info, parent, derivedClient)
|
||||
return await this.createCommentPromise
|
||||
}
|
||||
const review = info.external as ReviewExternalData
|
||||
|
||||
const account = existing?.modifiedBy ?? (await this.provider.getAccount(review.author))?._id ?? core.account.System
|
||||
|
||||
const messageData: ReviewData = {
|
||||
body: await this.provider.getMarkup(container.container, review.body),
|
||||
state: toReviewState(review.state),
|
||||
comments: (review.comments?.nodes ?? []).map((it) => it.url)
|
||||
}
|
||||
if (existing === undefined) {
|
||||
try {
|
||||
await this.createReview(info, messageData, parent, review, 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, review, account)
|
||||
}
|
||||
return { current: messageData, needSync: githubSyncVersion }
|
||||
}
|
||||
|
||||
private async handleDiffUpdate (
|
||||
existing: Doc,
|
||||
info: DocSyncInfo,
|
||||
reviewData: ReviewData,
|
||||
container: ContainerFocus,
|
||||
parent: DocSyncInfo,
|
||||
review: ReviewExternalData,
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const repository = container.repository.find((it) => it._id === info.repository)
|
||||
if (repository === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingReview = existing as GithubReview
|
||||
|
||||
const previousData: ReviewData = info.current ?? ({} as unknown as ReviewData)
|
||||
|
||||
const update = collectUpdate<GithubReview>(previousData, reviewData, Object.keys(reviewData))
|
||||
|
||||
const platformUpdate = collectUpdate<GithubReview>(previousData, existing, Object.keys(reviewData))
|
||||
|
||||
// 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) {
|
||||
// Check and update body with external
|
||||
// No update is possible for review.
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await this.client.update(existing, update, false, new Date(review.updatedAt ?? Date.now()).getTime(), account)
|
||||
}
|
||||
}
|
||||
|
||||
private async createReview (
|
||||
info: DocSyncInfo,
|
||||
messageData: ReviewData,
|
||||
parent: DocSyncInfo,
|
||||
review: ReviewExternalData,
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const _id: Ref<GithubReview> = info._id as unknown as Ref<GithubReview>
|
||||
const value: AttachedData<GithubReview> = {
|
||||
...messageData
|
||||
}
|
||||
await this.client.addCollection(
|
||||
github.class.GithubReview,
|
||||
info.space,
|
||||
parent._id,
|
||||
parent.objectClass,
|
||||
'activity',
|
||||
value,
|
||||
_id,
|
||||
new Date(review.submittedAt ?? review.createdAt ?? Date.now()).getTime(),
|
||||
account
|
||||
)
|
||||
}
|
||||
|
||||
async createGithubReview (
|
||||
container: ContainerFocus,
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo,
|
||||
derivedClient: TxOperations
|
||||
): Promise<DocumentUpdate<DocSyncInfo>> {
|
||||
// TODO: Use selected repository
|
||||
const repo = container.repository.find((it) => it._id === 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 GithubReview
|
||||
const okit =
|
||||
(await this.provider.getOctokit(existingReview.modifiedBy as Ref<PersonAccount>)) ?? container.container.octokit
|
||||
|
||||
// No external version yet, create it.
|
||||
try {
|
||||
// TOOD: Collect all threads and all pending comments to be added, and map them back.
|
||||
const q = `mutation createReview($prID: ID!, $body: String!, $state: PullRequestReviewEvent!) {
|
||||
addPullRequestReview(input:{
|
||||
pullRequestId: $prID,
|
||||
body: $body,
|
||||
event: $state
|
||||
}) {
|
||||
pullRequestReview {
|
||||
${reviewDetails}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
if (isGHWriteAllowed()) {
|
||||
const response:
|
||||
| {
|
||||
addPullRequestReview: {
|
||||
pullRequestReview: ReviewExternalData
|
||||
}
|
||||
}
|
||||
| undefined = await okit?.graphql(q, {
|
||||
prID: (parent.external as PullRequestExternalData).id,
|
||||
body: (await this.provider.getMarkdown(existingReview.body)) ?? '',
|
||||
state: existingReview.state
|
||||
})
|
||||
|
||||
const reviewExternal = response?.addPullRequestReview?.pullRequestReview
|
||||
|
||||
if (reviewExternal !== undefined) {
|
||||
const upd: DocumentUpdate<DocSyncInfo> = {
|
||||
url: reviewExternal.url.toLowerCase(),
|
||||
external: reviewExternal,
|
||||
current: existing,
|
||||
repository: repo._id,
|
||||
version: githubSyncVersion,
|
||||
externalVersion: githubExternalSyncVersion
|
||||
}
|
||||
// We need to update in current promise, to prevent event changes.
|
||||
await derivedClient.update(info, upd)
|
||||
}
|
||||
}
|
||||
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<void> {
|
||||
// No need to perform external sync for reviews, so let's update marks
|
||||
const tx = derivedClient.apply('reviews_github')
|
||||
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<void> {
|
||||
// No external sync for reviews, they are done in pull requests.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const syncConfig = {
|
||||
MainProject: false,
|
||||
SupportMilestones: true,
|
||||
IssuesInProject: true,
|
||||
BacklogInProject: false,
|
||||
PullRequestsInProject: false
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { Doc, DocumentUpdate, MeasureContext, TxOperations } from '@hcengineering/core'
|
||||
import { LiveQuery } from '@hcengineering/query'
|
||||
import { DocSyncInfo, GithubIntegrationRepository, GithubProject } from '@hcengineering/github'
|
||||
import { Octokit } from 'octokit'
|
||||
import { DocSyncManager, ExternalSyncField, IntegrationContainer, IntegrationManager } from '../types'
|
||||
import { UserInfo } from './githubTypes'
|
||||
|
||||
export class UsersSyncManager implements DocSyncManager {
|
||||
provider!: IntegrationManager
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly client: TxOperations,
|
||||
readonly lq: LiveQuery
|
||||
) {}
|
||||
|
||||
externalDerivedSync = false
|
||||
|
||||
async init (provider: IntegrationManager): Promise<void> {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
async sync (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent?: DocSyncInfo
|
||||
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
||||
return {}
|
||||
}
|
||||
|
||||
async handleDelete (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean
|
||||
): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {}
|
||||
|
||||
async externalSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
kind: ExternalSyncField,
|
||||
syncDocs: DocSyncInfo[],
|
||||
repo: GithubIntegrationRepository,
|
||||
prj: GithubProject
|
||||
): Promise<void> {}
|
||||
|
||||
repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {
|
||||
integration.synchronized.delete(`${repo._id}:users`)
|
||||
}
|
||||
|
||||
async externalFullSync (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
projects: GithubProject[],
|
||||
repositories: GithubIntegrationRepository[]
|
||||
): Promise<void> {
|
||||
for (const repo of repositories) {
|
||||
const syncKey = `${repo._id}:users`
|
||||
if (
|
||||
repo.githubProject === undefined ||
|
||||
!repo.enabled ||
|
||||
integration.synchronized.has(syncKey) ||
|
||||
integration.octokit === undefined ||
|
||||
repo.nodeId === undefined
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
await this.syncUsers('assignableUsers', integration, repo)
|
||||
await this.syncUsers('mentionableUsers', integration, repo)
|
||||
|
||||
integration.synchronized.add(syncKey)
|
||||
}
|
||||
}
|
||||
|
||||
async syncUsers (key: string, integration: IntegrationContainer, repo: GithubIntegrationRepository): Promise<void> {
|
||||
const assignableUsersIterator = integration.octokit.graphql.paginate.iterator(
|
||||
`query listUsers($name: String!, $owner: String!, $cursor: String) {
|
||||
repository(name: $name, owner: $owner) {
|
||||
${key}(first: 50, after: $cursor) {
|
||||
nodes {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
login
|
||||
name
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
name: repo.name,
|
||||
owner: repo.owner?.login ?? ''
|
||||
}
|
||||
)
|
||||
try {
|
||||
for await (const data of assignableUsersIterator) {
|
||||
const users: UserInfo[] = data.repository[key]?.nodes ?? []
|
||||
for (const d of users) {
|
||||
if (d.login !== undefined) {
|
||||
try {
|
||||
await this.provider.getAccount(d)
|
||||
continue
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Error', { err })
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Error', { err })
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchViewerDetails (okit: Octokit): Promise<{
|
||||
viewer: {
|
||||
followers: {
|
||||
totalCount: number
|
||||
}
|
||||
following: {
|
||||
totalCount: number
|
||||
}
|
||||
repositories: {
|
||||
totalCount: number
|
||||
}
|
||||
openIssues: {
|
||||
totalCount: number
|
||||
}
|
||||
closedIssues: {
|
||||
totalCount: number
|
||||
}
|
||||
openPRs: {
|
||||
totalCount: number
|
||||
}
|
||||
mergedPRs: {
|
||||
totalCount: number
|
||||
}
|
||||
closedPRs: {
|
||||
totalCount: number
|
||||
}
|
||||
repositoryDiscussions: {
|
||||
totalCount: number
|
||||
}
|
||||
repositoriesContributedTo: {
|
||||
totalCount: number
|
||||
}
|
||||
starredRepositories: {
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
id: string
|
||||
login: string
|
||||
email: string | undefined
|
||||
url: string | undefined
|
||||
name: string | undefined
|
||||
bio: string | undefined
|
||||
location: string | undefined
|
||||
company: string | undefined
|
||||
avatarUrl: string | undefined
|
||||
createdAt: string | undefined
|
||||
updatedAt: string | undefined
|
||||
organizations: {
|
||||
totalCount: number
|
||||
nodes: {
|
||||
url: string
|
||||
avatarUrl: string | undefined
|
||||
name: string | undefined
|
||||
description: string | undefined
|
||||
archivedAt: string | undefined
|
||||
email: string | undefined
|
||||
viewerIsAMember: boolean
|
||||
updatedAt: string | undefined
|
||||
resourcePath: string | undefined
|
||||
descriptionHTML: string | undefined
|
||||
location: string | undefined
|
||||
websiteUrl: string | undefined
|
||||
}[]
|
||||
}
|
||||
}
|
||||
}> {
|
||||
const request = `
|
||||
{
|
||||
viewer {
|
||||
followers(first:0) {
|
||||
totalCount
|
||||
}
|
||||
following(first:0) {
|
||||
totalCount
|
||||
}
|
||||
repositories(first:0) {
|
||||
totalCount
|
||||
}
|
||||
openIssues:issues(first:0, states:[OPEN]) {
|
||||
totalCount
|
||||
}
|
||||
closedIssues:issues (first:0, states:[CLOSED]) {
|
||||
totalCount
|
||||
}
|
||||
|
||||
openPRs: pullRequests(first:0, states:OPEN) {
|
||||
totalCount
|
||||
}
|
||||
mergedPRs: pullRequests(first:0, states:MERGED) {
|
||||
totalCount
|
||||
}
|
||||
closedPRs: pullRequests(first:0, states:CLOSED) {
|
||||
totalCount
|
||||
}
|
||||
repositoryDiscussions(first:0) {
|
||||
totalCount
|
||||
}
|
||||
repositoriesContributedTo(first:0) {
|
||||
totalCount
|
||||
}
|
||||
starredRepositories(first:0) {
|
||||
totalCount
|
||||
}
|
||||
|
||||
id
|
||||
login
|
||||
email
|
||||
url
|
||||
name
|
||||
bio
|
||||
location
|
||||
company
|
||||
avatarUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
organizations(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
url
|
||||
avatarUrl
|
||||
name
|
||||
resourcePath
|
||||
description
|
||||
archivedAt
|
||||
email
|
||||
viewerIsAMember
|
||||
archivedAt
|
||||
updatedAt
|
||||
description
|
||||
descriptionHTML
|
||||
location
|
||||
websiteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
return await okit.graphql(request)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import core, {
|
||||
Account,
|
||||
AnyAttribute,
|
||||
AttachedDoc,
|
||||
Class,
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
DocumentUpdate,
|
||||
MeasureContext,
|
||||
Ref,
|
||||
SortingOrder,
|
||||
Status,
|
||||
Timestamp,
|
||||
TxOperations,
|
||||
Type,
|
||||
toIdMap
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, unknownStatus } from '@hcengineering/platform'
|
||||
import task, { TaskType, calculateStatuses, createState, findStatusAttr } from '@hcengineering/task'
|
||||
import tracker, { IssueStatus } from '@hcengineering/tracker'
|
||||
import github, {
|
||||
DocSyncInfo,
|
||||
GithubIntegrationRepository,
|
||||
GithubIssueStateReason,
|
||||
GithubProject
|
||||
} from '@hcengineering/github'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { IntegrationManager, githubExternalSyncVersion } from '../types'
|
||||
import { GithubDataType } from './githubTypes'
|
||||
|
||||
/**
|
||||
* Return if github write operations are allowed.
|
||||
*/
|
||||
export function isGHWriteAllowed (): boolean {
|
||||
if (process.env.GITHUB_READONLY === 'true') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function collectUpdate<T extends Doc> (
|
||||
doc: Record<string, any>,
|
||||
newDoc: Record<string, any>,
|
||||
keys: string[]
|
||||
): DocumentUpdate<T> {
|
||||
const documentUpdate: DocumentUpdate<Doc> = {}
|
||||
function toUndefinedValues (a: any): any {
|
||||
if (typeof a === 'object' && a != null) {
|
||||
const newA: any = {}
|
||||
for (const [k, v] of Object.entries(a)) {
|
||||
if (v === null) {
|
||||
newA[k] = undefined
|
||||
} else {
|
||||
newA[k] = toUndefinedValues(v)
|
||||
}
|
||||
}
|
||||
return newA
|
||||
}
|
||||
return a ?? undefined
|
||||
}
|
||||
for (const k of keys) {
|
||||
const v = newDoc[k]
|
||||
if (!keys.includes(k)) {
|
||||
continue
|
||||
}
|
||||
if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) {
|
||||
continue
|
||||
}
|
||||
let vv = v
|
||||
if (vv === undefined) {
|
||||
vv = null
|
||||
}
|
||||
const dv = (doc as any)[k]
|
||||
if (!deepEqual(toUndefinedValues(dv), toUndefinedValues(v))) {
|
||||
;(documentUpdate as any)[k] = vv
|
||||
}
|
||||
}
|
||||
return documentUpdate as DocumentUpdate<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getSince (
|
||||
_client: TxOperations,
|
||||
_class: Ref<Class<Doc>>,
|
||||
repo: GithubIntegrationRepository
|
||||
): Promise<string | undefined> {
|
||||
const lastModified: Timestamp | undefined = await getSinceRaw(_client, _class, repo)
|
||||
return lastModified !== undefined ? new Date(lastModified + 1)?.toISOString() : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getSinceRaw (
|
||||
_client: TxOperations,
|
||||
_class: Ref<Class<Doc>>,
|
||||
repo: GithubIntegrationRepository
|
||||
): Promise<number | undefined> {
|
||||
if (repo.githubProject == null) {
|
||||
return undefined
|
||||
}
|
||||
return (
|
||||
await _client.findOne(
|
||||
github.class.DocSyncInfo,
|
||||
{
|
||||
objectClass: _class,
|
||||
space: repo.githubProject,
|
||||
lastModified: { $exists: true },
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
externalVersionSince: { $ne: '#' },
|
||||
repository: repo._id
|
||||
},
|
||||
{ sort: { lastModified: SortingOrder.Descending }, limit: 1 }
|
||||
)
|
||||
)?.lastModified
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function gqlp (params: Record<string, string | number | string[] | undefined>): string {
|
||||
let result = ''
|
||||
let first = true
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) {
|
||||
if (!first) {
|
||||
result += ', '
|
||||
}
|
||||
first = false
|
||||
if (typeof v === 'number') {
|
||||
result += `${k}: ${v}`
|
||||
} else if (Array.isArray(v)) {
|
||||
result += `${k}: [${v.map((it) => `"${it}"`).join(', ')}]`
|
||||
} else {
|
||||
result += `${k}: "${v}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getCreateStatus (
|
||||
ctx: MeasureContext,
|
||||
provider: IntegrationManager,
|
||||
client: TxOperations,
|
||||
prj: GithubProject,
|
||||
name: string,
|
||||
description: string,
|
||||
colorStr: string,
|
||||
taskType: TaskType
|
||||
): Promise<string> {
|
||||
const color = hashCode(colorStr)
|
||||
|
||||
const states = await provider.getStatuses(taskType._id)
|
||||
|
||||
for (const s of states) {
|
||||
if (s.name.toLowerCase().trim() === name.toLowerCase().trim()) {
|
||||
return s._id
|
||||
}
|
||||
}
|
||||
ctx.error('Create new project Status', { name, colorStr, category: 'Backlog' })
|
||||
// No status found, let's create one.
|
||||
const id = await createState(client, taskType.statusClass, {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
ofAttribute: findStatusAttr(client.getHierarchy(), taskType.statusClass)._id,
|
||||
category: task.statusCategory.UnStarted
|
||||
})
|
||||
const type = await client.findOne(task.class.ProjectType, { _id: prj.type })
|
||||
if (type === undefined) {
|
||||
return id
|
||||
}
|
||||
|
||||
if (!taskType.statuses.includes(id)) {
|
||||
await client.update(taskType, {
|
||||
$push: { statuses: id }
|
||||
})
|
||||
const taskTypes = toIdMap(await client.findAll(task.class.TaskType, { parent: type._id }))
|
||||
|
||||
const index = type.statuses.findIndex((it) => it._id === id)
|
||||
if (index === -1) {
|
||||
await client.update(type, {
|
||||
statuses: calculateStatuses(type, taskTypes, [{ taskTypeId: taskType._id, statuses: taskType.statuses }])
|
||||
})
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function hashCode (str: string): number {
|
||||
return str.split('').reduce((prevHash, currVal) => ((prevHash << 5) - prevHash + currVal.charCodeAt(0)) | 0, 0)
|
||||
}
|
||||
|
||||
export function getType (attr: AnyAttribute): GithubDataType | undefined {
|
||||
if (attr.type._class === core.class.TypeString) {
|
||||
return 'TEXT'
|
||||
}
|
||||
if (
|
||||
attr.type._class === core.class.TypeNumber ||
|
||||
attr.type._class === tracker.class.TypeReportedTime ||
|
||||
attr.type._class === tracker.class.TypeEstimation ||
|
||||
attr.type._class === tracker.class.TypeRemainingTime
|
||||
) {
|
||||
return 'NUMBER'
|
||||
}
|
||||
if (attr.type._class === core.class.TypeDate) {
|
||||
return 'DATE'
|
||||
}
|
||||
if (attr.type._class === core.class.EnumOf) {
|
||||
return 'SINGLE_SELECT'
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatformType (dataType: GithubDataType): Ref<Class<Type<any>>> | undefined {
|
||||
switch (dataType) {
|
||||
case 'TEXT':
|
||||
return core.class.TypeString
|
||||
case 'NUMBER':
|
||||
return core.class.TypeNumber
|
||||
case 'DATE':
|
||||
return core.class.TypeDate
|
||||
case 'SINGLE_SELECT':
|
||||
return core.class.EnumOf
|
||||
}
|
||||
}
|
||||
|
||||
export async function guessStatus (
|
||||
pr: { state: 'OPEN' | 'CLOSED' | 'MERGED', stateReason?: GithubIssueStateReason | null },
|
||||
statuses: Status[]
|
||||
): Promise<IssueStatus> {
|
||||
const unstarted = (): Status | undefined => statuses.find((it) => it.category === task.statusCategory.UnStarted)
|
||||
|
||||
const todo = (): Status | undefined => statuses.find((it) => it.category === task.statusCategory.ToDo)
|
||||
const active = (): Status | undefined => statuses.find((it) => it.category === task.statusCategory.Active)
|
||||
|
||||
const canceled = (): Status | undefined => statuses.find((it) => it.category === task.statusCategory.Lost)
|
||||
const completed = (): Status | undefined => statuses.find((it) => it.category === task.statusCategory.Won)
|
||||
|
||||
let result: IssueStatus | undefined
|
||||
|
||||
if (pr.state === 'OPEN' && pr.stateReason == null) {
|
||||
result = unstarted() ?? todo() ?? active()
|
||||
} else if (pr.state === 'OPEN' && pr.stateReason === GithubIssueStateReason.Reopened) {
|
||||
result = active()
|
||||
} else if (pr.state === 'CLOSED' && pr.stateReason === GithubIssueStateReason.NotPlanned) {
|
||||
result = canceled()
|
||||
} else if (pr.state === 'CLOSED' || pr.state === 'MERGED') {
|
||||
result = completed()
|
||||
} else {
|
||||
// By default put into backlog
|
||||
result = unstarted() ?? todo() ?? active()
|
||||
}
|
||||
if (result === undefined) {
|
||||
throw new PlatformError(unknownStatus(`No status found for GH issue status ${pr.state} ${pr.stateReason}`))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class SyncRunner {
|
||||
eventSync = new Map<string, Promise<void>>()
|
||||
|
||||
async exec<T>(id: string, op: () => Promise<T>): Promise<T> {
|
||||
await this.eventSync.get(id)
|
||||
const promise = op()
|
||||
this.eventSync.set(
|
||||
id,
|
||||
promise.then(() => {})
|
||||
)
|
||||
const result = await promise
|
||||
this.eventSync.delete(id)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const syncRunner = new SyncRunner()
|
||||
|
||||
export async function deleteObjects (
|
||||
ctx: MeasureContext,
|
||||
client: TxOperations,
|
||||
objects: Doc[],
|
||||
account: Ref<Account>
|
||||
): Promise<void> {
|
||||
const ops = client.apply('delete')
|
||||
for (const object of objects) {
|
||||
if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
|
||||
const adoc = object as AttachedDoc
|
||||
await ops
|
||||
.removeCollection(
|
||||
object._class,
|
||||
object.space,
|
||||
adoc._id,
|
||||
adoc.attachedTo,
|
||||
adoc.attachedToClass,
|
||||
adoc.collection,
|
||||
Date.now(),
|
||||
account
|
||||
)
|
||||
.catch((err) => {
|
||||
Analytics.handleError(err)
|
||||
ctx.error('filed to remove collection', err)
|
||||
})
|
||||
} else {
|
||||
await ops.removeDoc(object._class, object.space, object._id, Date.now(), account).catch((err) => {
|
||||
Analytics.handleError(err)
|
||||
ctx.error('filed to remove doc', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
await ops.commit()
|
||||
}
|
||||
|
||||
export async function syncDerivedDocuments<T extends { url: string }> (
|
||||
derivedClient: TxOperations,
|
||||
parentDoc: DocSyncInfo,
|
||||
ext: T,
|
||||
prj: GithubProject,
|
||||
repo: GithubIntegrationRepository,
|
||||
objectClass: Ref<Class<Doc>>,
|
||||
query: DocumentQuery<DocSyncInfo>,
|
||||
docs: (ext: T) => { url: string, updatedAt: string | null, createdAt: string }[],
|
||||
extra?: any
|
||||
): Promise<void> {
|
||||
const childDocsOfClass = await derivedClient.findAll(github.class.DocSyncInfo, {
|
||||
objectClass,
|
||||
parent: (parentDoc.url ?? '').toLowerCase(),
|
||||
...query
|
||||
})
|
||||
|
||||
const processed = new Set<Ref<DocSyncInfo>>()
|
||||
const _docs = docs(ext)
|
||||
for (const r of _docs) {
|
||||
const existing = childDocsOfClass.find((it) => it.url.toLowerCase() === r.url.toLowerCase())
|
||||
if (existing === undefined) {
|
||||
await derivedClient.createDoc<DocSyncInfo>(github.class.DocSyncInfo, prj._id, {
|
||||
objectClass,
|
||||
url: r.url.toLowerCase(),
|
||||
needSync: '', // we need to sync to retrieve patch in background
|
||||
githubNumber: 0,
|
||||
repository: repo._id,
|
||||
external: r,
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
derivedVersion: '',
|
||||
lastModified: new Date(r.updatedAt ?? r.createdAt).getTime(),
|
||||
parent: ext.url,
|
||||
attachedTo: parentDoc._id,
|
||||
...extra
|
||||
})
|
||||
} else {
|
||||
processed.add(existing._id)
|
||||
if (!deepEqual(existing.external, r)) {
|
||||
// Only update if had changes.
|
||||
await derivedClient.update(existing, {
|
||||
external: r,
|
||||
needSync: '', // We need to check if we had any changes.
|
||||
derivedVersion: '',
|
||||
externalVersion: githubExternalSyncVersion,
|
||||
lastModified: new Date(r.updatedAt ?? r.createdAt).getTime(),
|
||||
...extra
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all non processed for delete.
|
||||
for (const d of childDocsOfClass.filter((it) => !processed.has(it._id))) {
|
||||
await derivedClient.update<DocSyncInfo>(d, { deleted: true, needSync: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const errorPrinter = ({ message, stack, ...rest }: Error): object => ({
|
||||
message,
|
||||
stack,
|
||||
...rest
|
||||
})
|
||||
export function errorToObj (value: any): any {
|
||||
return value instanceof Error ? errorPrinter(value) : value
|
||||
}
|
||||
|
||||
export function compareMarkdown (a: string, b: string): boolean {
|
||||
let na = a.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
|
||||
let nb = b.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
|
||||
|
||||
// Remove trailings before compare
|
||||
na = na
|
||||
.split('\n')
|
||||
.map((it) => it.trimEnd())
|
||||
.join('\n')
|
||||
nb = nb
|
||||
.split('\n')
|
||||
.map((it) => it.trimEnd())
|
||||
.join('\n')
|
||||
|
||||
return na === nb
|
||||
}
|
||||
Reference in New Issue
Block a user