mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-30 19:59:47 +02:00
Move services to public (#6156)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { ClientWorkspaceInfo } from '@hcengineering/account'
|
||||
import config from './config'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function getWorkspaceInfo (token: string): Promise<ClientWorkspaceInfo> {
|
||||
const accountsUrl = config.AccountsURL
|
||||
const workspaceInfo = await (
|
||||
await fetch(accountsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: 'getWorkspaceInfo',
|
||||
params: []
|
||||
})
|
||||
})
|
||||
).json()
|
||||
|
||||
return workspaceInfo.result as ClientWorkspaceInfo
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
//
|
||||
|
||||
import client, { ClientSocket } from '@hcengineering/client'
|
||||
import clientResources from '@hcengineering/client-resources'
|
||||
import { Client, ClientConnectEvent } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import { getTransactorEndpoint } from '@hcengineering/server-client'
|
||||
import serverToken, { generateToken } from '@hcengineering/server-token'
|
||||
import WebSocket from 'ws'
|
||||
import config from './config'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function createPlatformClient (
|
||||
workspace: string,
|
||||
productId: string,
|
||||
timeout: number,
|
||||
reconnect?: (event: ClientConnectEvent) => void
|
||||
): Promise<Client> {
|
||||
setMetadata(client.metadata.ClientSocketFactory, (url) => {
|
||||
return new WebSocket(url, {
|
||||
headers: {
|
||||
'User-Agent': config.ServiceID
|
||||
}
|
||||
}) as never as ClientSocket
|
||||
})
|
||||
|
||||
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
|
||||
const token = generateToken(
|
||||
config.SystemEmail,
|
||||
{
|
||||
name: workspace,
|
||||
productId
|
||||
},
|
||||
{ mode: 'github' }
|
||||
)
|
||||
setMetadata(client.metadata.ConnectionTimeout, timeout)
|
||||
const endpoint = await getTransactorEndpoint(token)
|
||||
const connection = await (
|
||||
await clientResources()
|
||||
).function.GetClient(token, endpoint, {
|
||||
onConnect: reconnect
|
||||
})
|
||||
|
||||
return connection
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
//
|
||||
|
||||
import { CollaboratorClient, getClient as getCollaboratorClient } from '@hcengineering/collaborator-client'
|
||||
import { Hierarchy, WorkspaceId } from '@hcengineering/core'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import config from './config'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function createCollaboratorClient (hierarchy: Hierarchy, workspaceId: WorkspaceId): CollaboratorClient {
|
||||
const token = generateToken(config.SystemEmail, workspaceId, { mode: 'github' })
|
||||
return getCollaboratorClient(hierarchy, workspaceId, token, config.CollaboratorURL)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { systemAccountEmail } from '@hcengineering/core'
|
||||
|
||||
interface Config {
|
||||
AccountsURL: string
|
||||
ServiceID: string
|
||||
ServerSecret: string
|
||||
SystemEmail: string
|
||||
FrontURL: string
|
||||
|
||||
// '*' means all workspaces
|
||||
AllowedWorkspaces: string[]
|
||||
AppID: string
|
||||
ClientID: string
|
||||
ClientSecret: string
|
||||
PrivateKey: string
|
||||
WebhookSecret: string
|
||||
EnterpriseHostname: string
|
||||
Port: number
|
||||
|
||||
MongoURL: string
|
||||
ConfigurationDB: string
|
||||
|
||||
CollaboratorURL: string
|
||||
|
||||
ProductID: string
|
||||
|
||||
BotName: string
|
||||
|
||||
SentryDSN: string
|
||||
BrandingPath: string
|
||||
}
|
||||
|
||||
const envMap: { [key in keyof Config]: string } = {
|
||||
AccountsURL: 'ACCOUNTS_URL',
|
||||
ServiceID: 'SERVICE_ID',
|
||||
ServerSecret: 'SERVER_SECRET',
|
||||
SystemEmail: 'SYSTEM_EMAIL',
|
||||
FrontURL: 'FRONT_URL',
|
||||
|
||||
AppID: 'APP_ID',
|
||||
ClientID: 'CLIENT_ID',
|
||||
ClientSecret: 'CLIENT_SECRET',
|
||||
PrivateKey: 'PRIVATE_KEY',
|
||||
WebhookSecret: 'WEBHOOK_SECRET',
|
||||
EnterpriseHostname: 'ENTERPRISE_HOSTNAME',
|
||||
Port: 'PORT',
|
||||
AllowedWorkspaces: 'ALLOWED_WORKSPACES',
|
||||
BotName: 'BOT_NAME',
|
||||
|
||||
MongoURL: 'MONGO_URL',
|
||||
ConfigurationDB: 'MONGO_DB',
|
||||
|
||||
CollaboratorURL: 'COLLABORATOR_API_URL',
|
||||
|
||||
ProductID: 'PRODUCT_ID',
|
||||
|
||||
SentryDSN: 'SENTRY_DSN',
|
||||
BrandingPath: 'BRANDING_PATH'
|
||||
}
|
||||
|
||||
const required: Array<keyof Config> = [
|
||||
'AccountsURL',
|
||||
'ServerSecret',
|
||||
'ServiceID',
|
||||
'SystemEmail',
|
||||
'FrontURL',
|
||||
'AppID',
|
||||
'ClientID',
|
||||
'ClientSecret',
|
||||
'PrivateKey',
|
||||
|
||||
'MongoURL',
|
||||
'ConfigurationDB',
|
||||
|
||||
'CollaboratorURL',
|
||||
|
||||
'ProductID',
|
||||
'BotName'
|
||||
]
|
||||
|
||||
const config: Config = (() => {
|
||||
const params: Partial<Config> = {
|
||||
AccountsURL: process.env[envMap.AccountsURL],
|
||||
ServerSecret: process.env[envMap.ServerSecret],
|
||||
ServiceID: process.env[envMap.ServiceID] ?? 'github-service',
|
||||
SystemEmail: process.env[envMap.SystemEmail] ?? systemAccountEmail,
|
||||
AllowedWorkspaces: process.env[envMap.AllowedWorkspaces]?.split(',') ?? ['*'],
|
||||
FrontURL: process.env[envMap.FrontURL] ?? '',
|
||||
|
||||
AppID: process.env[envMap.AppID],
|
||||
ClientID: process.env[envMap.ClientID],
|
||||
ClientSecret: process.env[envMap.ClientSecret],
|
||||
// https://github.com/octokit/auth-app.js/issues/465
|
||||
PrivateKey: process.env[envMap.PrivateKey]?.replace(/\\n/g, '\n'),
|
||||
WebhookSecret: process.env[envMap.WebhookSecret] ?? 'secret',
|
||||
EnterpriseHostname: process.env[envMap.EnterpriseHostname],
|
||||
Port: parseInt(process.env[envMap.Port] ?? '3500'),
|
||||
BotName: process.env[envMap.BotName] ?? 'dev[bot]',
|
||||
|
||||
MongoURL: process.env[envMap.MongoURL],
|
||||
ConfigurationDB: process.env[envMap.ConfigurationDB] ?? '%github',
|
||||
|
||||
CollaboratorURL: process.env[envMap.CollaboratorURL],
|
||||
|
||||
ProductID: process.env[envMap.ProductID] ?? '',
|
||||
|
||||
SentryDSN: process.env[envMap.SentryDSN],
|
||||
BrandingPath: process.env[envMap.BrandingPath] ?? ''
|
||||
}
|
||||
|
||||
const missingEnv = required.filter((key) => params[key] === undefined).map((key) => envMap[key])
|
||||
|
||||
if (missingEnv.length > 0) {
|
||||
throw Error(`Missing env variables: ${missingEnv.join(', ')}`)
|
||||
}
|
||||
|
||||
return params as Config
|
||||
})()
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { MeasureMetricsContext, metricsToString, newMetrics } from '@hcengineering/core'
|
||||
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import config from './config'
|
||||
import { start } from './server'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { loadBrandingMap } from '@hcengineering/server-core'
|
||||
|
||||
// Load and inc startID, to have easy logs.
|
||||
|
||||
const metricsContext = new MeasureMetricsContext(
|
||||
'github',
|
||||
{},
|
||||
{},
|
||||
newMetrics(),
|
||||
new SplitLogger('github-service', {
|
||||
root: join(process.cwd(), 'logs'),
|
||||
enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true'
|
||||
})
|
||||
)
|
||||
|
||||
configureAnalytics(config.SentryDSN, config)
|
||||
Analytics.setTag('application', 'github-service')
|
||||
|
||||
let oldMetricsValue = ''
|
||||
|
||||
const intTimer = setInterval(() => {
|
||||
const val = metricsToString(metricsContext.metrics, 'Github', 140)
|
||||
if (val !== oldMetricsValue) {
|
||||
oldMetricsValue = val
|
||||
void writeFile('metrics.txt', val).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
void start(metricsContext, loadBrandingMap(config.BrandingPath))
|
||||
|
||||
const onClose = (): void => {
|
||||
clearInterval(intTimer)
|
||||
metricsContext.info('Closed')
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (e) => {
|
||||
metricsContext.error('UncaughtException', { error: e })
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
metricsContext.error('Unhandled Rejection at:', { promise, reason })
|
||||
})
|
||||
|
||||
process.on('SIGINT', onClose)
|
||||
process.on('SIGTERM', onClose)
|
||||
process.on('exit', onClose)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { coreId } from '@hcengineering/core'
|
||||
|
||||
import { activityId } from '@hcengineering/activity'
|
||||
import { attachmentId } from '@hcengineering/attachment'
|
||||
import { bitrixId } from '@hcengineering/bitrix'
|
||||
import { boardId } from '@hcengineering/board'
|
||||
import { calendarId } from '@hcengineering/calendar'
|
||||
import { chunterId } from '@hcengineering/chunter'
|
||||
import { contactId } from '@hcengineering/contact'
|
||||
import { driveId } from '@hcengineering/drive'
|
||||
import { gmailId } from '@hcengineering/gmail'
|
||||
import { hrId } from '@hcengineering/hr'
|
||||
import { inventoryId } from '@hcengineering/inventory'
|
||||
import { leadId } from '@hcengineering/lead'
|
||||
import { loginId } from '@hcengineering/login'
|
||||
import { notificationId } from '@hcengineering/notification'
|
||||
import { preferenceId } from '@hcengineering/preference'
|
||||
import { recruitId } from '@hcengineering/recruit'
|
||||
import { requestId } from '@hcengineering/request'
|
||||
import { settingId } from '@hcengineering/setting'
|
||||
import { supportId } from '@hcengineering/support'
|
||||
import { tagsId } from '@hcengineering/tags'
|
||||
import { taskId } from '@hcengineering/task'
|
||||
import { telegramId } from '@hcengineering/telegram'
|
||||
import { templatesId } from '@hcengineering/templates'
|
||||
import { trackerId } from '@hcengineering/tracker'
|
||||
import { viewId } from '@hcengineering/view'
|
||||
import { workbenchId } from '@hcengineering/workbench'
|
||||
import { documentId } from '@hcengineering/document'
|
||||
import { githubId } from '@hcengineering/github'
|
||||
|
||||
import activityEn from '@hcengineering/activity-assets/lang/en.json'
|
||||
import attachmentEn from '@hcengineering/attachment-assets/lang/en.json'
|
||||
import bitrixEn from '@hcengineering/bitrix-assets/lang/en.json'
|
||||
import boardEn from '@hcengineering/board-assets/lang/en.json'
|
||||
import calendarEn from '@hcengineering/calendar-assets/lang/en.json'
|
||||
import chunterEn from '@hcengineering/chunter-assets/lang/en.json'
|
||||
import contactEn from '@hcengineering/contact-assets/lang/en.json'
|
||||
import coreEng from '@hcengineering/core/lang/en.json'
|
||||
import driveEn from '@hcengineering/drive-assets/lang/en.json'
|
||||
import gmailEn from '@hcengineering/gmail-assets/lang/en.json'
|
||||
import hrEn from '@hcengineering/hr-assets/lang/en.json'
|
||||
import inventoryEn from '@hcengineering/inventory-assets/lang/en.json'
|
||||
import leadEn from '@hcengineering/lead-assets/lang/en.json'
|
||||
import loginEng from '@hcengineering/login-assets/lang/en.json'
|
||||
import platformEng from '@hcengineering/platform/lang/en.json'
|
||||
import notificationEn from '@hcengineering/notification-assets/lang/en.json'
|
||||
import { addStringsLoader, platformId } from '@hcengineering/platform'
|
||||
import preferenceEn from '@hcengineering/preference-assets/lang/en.json'
|
||||
import recruitEn from '@hcengineering/recruit-assets/lang/en.json'
|
||||
import requestEn from '@hcengineering/request-assets/lang/en.json'
|
||||
import settingEn from '@hcengineering/setting-assets/lang/en.json'
|
||||
import supportEn from '@hcengineering/support-assets/lang/en.json'
|
||||
import tagsEn from '@hcengineering/tags-assets/lang/en.json'
|
||||
import taskEn from '@hcengineering/task-assets/lang/en.json'
|
||||
import telegramEn from '@hcengineering/telegram-assets/lang/en.json'
|
||||
import templatesEn from '@hcengineering/templates-assets/lang/en.json'
|
||||
import trackerEn from '@hcengineering/tracker-assets/lang/en.json'
|
||||
import viewEn from '@hcengineering/view-assets/lang/en.json'
|
||||
import workbenchEn from '@hcengineering/workbench-assets/lang/en.json'
|
||||
import documentEn from '@hcengineering/document-assets/lang/en.json'
|
||||
import githubEn from '@hcengineering/github-assets/lang/en.json'
|
||||
|
||||
export function registerLoaders (): void {
|
||||
addStringsLoader(coreId, async (lang: string) => coreEng)
|
||||
addStringsLoader(loginId, async (lang: string) => loginEng)
|
||||
addStringsLoader(platformId, async (lang: string) => platformEng)
|
||||
|
||||
addStringsLoader(taskId, async (lang: string) => taskEn)
|
||||
addStringsLoader(viewId, async (lang: string) => viewEn)
|
||||
addStringsLoader(chunterId, async (lang: string) => chunterEn)
|
||||
addStringsLoader(attachmentId, async (lang: string) => attachmentEn)
|
||||
addStringsLoader(contactId, async (lang: string) => contactEn)
|
||||
addStringsLoader(recruitId, async (lang: string) => recruitEn)
|
||||
addStringsLoader(activityId, async (lang: string) => activityEn)
|
||||
addStringsLoader(settingId, async (lang: string) => settingEn)
|
||||
addStringsLoader(telegramId, async (lang: string) => telegramEn)
|
||||
addStringsLoader(leadId, async (lang: string) => leadEn)
|
||||
addStringsLoader(gmailId, async (lang: string) => gmailEn)
|
||||
addStringsLoader(workbenchId, async (lang: string) => workbenchEn)
|
||||
addStringsLoader(inventoryId, async (lang: string) => inventoryEn)
|
||||
addStringsLoader(templatesId, async (lang: string) => templatesEn)
|
||||
addStringsLoader(notificationId, async (lang: string) => notificationEn)
|
||||
addStringsLoader(tagsId, async (lang: string) => tagsEn)
|
||||
addStringsLoader(calendarId, async (lang: string) => calendarEn)
|
||||
addStringsLoader(trackerId, async (lang: string) => trackerEn)
|
||||
addStringsLoader(boardId, async (lang: string) => boardEn)
|
||||
addStringsLoader(preferenceId, async (lang: string) => preferenceEn)
|
||||
addStringsLoader(hrId, async (lang: string) => hrEn)
|
||||
addStringsLoader(documentId, async (lang: string) => documentEn)
|
||||
addStringsLoader(bitrixId, async (lang: string) => bitrixEn)
|
||||
addStringsLoader(requestId, async (lang: string) => requestEn)
|
||||
addStringsLoader(supportId, async (lang: string) => supportEn)
|
||||
addStringsLoader(githubId, async (lang: string) => githubEn)
|
||||
addStringsLoader(driveId, async (lang: string) => driveEn)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// Copyright © 2020 Anticrm Platform Contributors.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
/* eslint-env jest */
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverCore from '@hcengineering/server-core'
|
||||
import { jsonToHTML, htmlToJSON } from '@hcengineering/text'
|
||||
import { markupToMarkdown, markdownToMarkup, parseMessageMarkdown, serializeMessage } from '..'
|
||||
import { appendGuestLinkToModel, stripGuestLink } from '../../sync/guest'
|
||||
import { GithubKit } from '../extensions'
|
||||
|
||||
const refUrl: string = 'ref://'
|
||||
const imageUrl: string = 'http://localhost'
|
||||
const guestUrl: string = 'http://localhost:8080/guest'
|
||||
|
||||
const extensions = [GithubKit]
|
||||
|
||||
describe('server', () => {
|
||||
it('embedded markup parsing', () => {
|
||||
const markdown = `test5
|
||||
|
||||
<img width="721" alt="Screenshot 2024-01-22 at 10 39 21" src="https://github.com/haiodo-dev/my-issues/assets/477235/2452713a-ede2-4e0d-a448-9b1687c95cd9">
|
||||
|
||||
<img alt="Screenshot 2024-01-22 at 10 39 26" src="https://github.com/haiodo-dev/my-issues/assets/477235/6a8799fd-242d-4e70-9eba-0a769eede71b">
|
||||
|
||||
`
|
||||
const json = parseMessageMarkdown(markdown, refUrl, imageUrl, guestUrl)
|
||||
|
||||
const html = jsonToHTML(json, extensions)
|
||||
|
||||
const json2 = htmlToJSON(html, extensions)
|
||||
const newMarkdown = serializeMessage(json2, refUrl, imageUrl)
|
||||
|
||||
console.log(newMarkdown)
|
||||
})
|
||||
it('add html link', () => {
|
||||
const markdown = `test5
|
||||
|
||||
<img width="721" alt="Screenshot 2024-01-22 at 10 39 21" src="https://github.com/haiodo-dev/my-issues/assets/477235/2452713a-ede2-4e0d-a448-9b1687c95cd9">
|
||||
|
||||
<img alt="Screenshot 2024-01-22 at 10 39 26" src="https://github.com/haiodo-dev/my-issues/assets/477235/6a8799fd-242d-4e70-9eba-0a769eede71b">
|
||||
|
||||
<sub>
|
||||
View at Huly <a href="https://github.com/haiodo-dev/my-issues/issues/1">TSK-1023</a>
|
||||
</sub>
|
||||
`
|
||||
|
||||
const json = parseMessageMarkdown(markdown, refUrl, imageUrl, guestUrl)
|
||||
console.log(json)
|
||||
const html = jsonToHTML(json, extensions)
|
||||
const json2 = htmlToJSON(html, extensions)
|
||||
const newMarkdown = serializeMessage(json2, refUrl, imageUrl)
|
||||
console.log(newMarkdown)
|
||||
})
|
||||
it('check parsing with sub', async () => {
|
||||
const markdown =
|
||||
'qwe4 qwe6\n\nqwe 77\n\nzzz2 3\n\n<sub>View in Huly <a href="http://localhost:8080/guest/github?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsaW5rSWQiOiI2NWRlYWU4NGExZTRkY2Q2OWFlMzlmOTMiLCJndWVzdCI6InRydWUiLCJlbWFpbCI6IiNndWVzdEBoYy5lbmdpbmVlcmluZyIsIndvcmtzcGFjZSI6ImdpdGh1YiIsInByb2R1Y3RJZCI6IiJ9.6RJjjnn9JgDxQmsZ3AmMj8aqHI7Px4BwPxqyMK83OyM">TSK-50</a></sub>'
|
||||
const json = parseMessageMarkdown(markdown, refUrl, imageUrl, guestUrl)
|
||||
setMetadata(serverCore.metadata.FrontUrl, 'http://localhost:8080')
|
||||
await stripGuestLink(json)
|
||||
const newMarkdown = serializeMessage(json, refUrl, imageUrl)
|
||||
console.log(json, newMarkdown)
|
||||
expect(newMarkdown).toBe(
|
||||
'qwe4 qwe6\n\nqwe 77\n\nzzz2 3'
|
||||
)
|
||||
})
|
||||
it('code block', async () => {
|
||||
const markdown = '```bash\n2\nbash qwe\n3\nbase qwe2\n4\nbaseh qwe4\n5\n```'
|
||||
const markup = markdownToMarkup(markdown)
|
||||
const markdown2 = await markupToMarkdown(markup)
|
||||
expect(markdown2).toBe(markdown)
|
||||
})
|
||||
|
||||
it('block image', async () => {
|
||||
const markdown = 'qwerty\n<img width="320" src="http://example.com/image" alt="image">'
|
||||
const markup = markdownToMarkup(markdown)
|
||||
const markdown2 = await markupToMarkdown(markup)
|
||||
expect(markdown2).toBe(markdown)
|
||||
})
|
||||
|
||||
it('inline image', async () => {
|
||||
const markdown = '* line 1\n* line 2\n <img width="320" src="http://example.com/image" alt="image">'
|
||||
const markup = markdownToMarkup(markdown)
|
||||
const markdown2 = await markupToMarkdown(markup)
|
||||
expect(markdown2).toBe(markdown)
|
||||
})
|
||||
|
||||
it('test view in serialization', async () => {
|
||||
const markdown = '```bash\n2\nbash qwe\n3\nbase qwe2\n4\nbaseh qwe4\n5\n```'
|
||||
const json = parseMessageMarkdown(markdown, refUrl, imageUrl, guestUrl)
|
||||
appendGuestLinkToModel(json, 'http://test.com', 'TSK-1235')
|
||||
const serializedMarkdown = serializeMessage(json, refUrl, imageUrl)
|
||||
await stripGuestLink(json)
|
||||
const serializedMarkdown2 = serializeMessage(json, refUrl, imageUrl)
|
||||
expect(serializedMarkdown).toBe(
|
||||
'```bash\n2\nbash qwe\n3\nbase qwe2\n4\nbaseh qwe4\n5\n```\n\n<sub><a href="http://test.com">Huly®: <b>TSK-1235</b></a></sub>'
|
||||
)
|
||||
expect(serializedMarkdown2).toBe('```bash\n2\nbash qwe\n3\nbase qwe2\n4\nbaseh qwe4\n5\n```')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Copyright © 2020 Anticrm Platform Contributors.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
/* eslint-env jest */
|
||||
import { htmlToJSON, jsonToHTML } from '@hcengineering/text'
|
||||
import { parseMessageMarkdown, serializeMessage } from '..'
|
||||
import { defaultExtensions } from '../extensions'
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { gunzipSync } from 'zlib'
|
||||
import { calcSørensenDiceCoefficient, isMarkdownsEquals } from '../compare'
|
||||
|
||||
const refUrl: string = 'ref://'
|
||||
const imageUrl: string = 'http://localhost'
|
||||
|
||||
const markdownSamples = JSON.parse(gunzipSync(readFileSync('./src/markdown/__tests__/markdown.json.gz')).toString())
|
||||
|
||||
const testStrings = Array.from((markdownSamples)).reduce((decodedStrings: { repoName: string, markdownSource: string, markdown: string }[], { markdownEncoded, markdownEncoding, repoName, markdownSource }: any) => {
|
||||
if (markdownEncoding === 'base64') {
|
||||
decodedStrings.push({ repoName, markdownSource, markdown: atob(markdownEncoded) })
|
||||
}
|
||||
|
||||
return decodedStrings
|
||||
}, [])
|
||||
|
||||
describe('server', () => {
|
||||
it('test-all', () => {
|
||||
let i = 0
|
||||
const dta = testStrings
|
||||
let errors: number = 0
|
||||
let minK = 100
|
||||
let minKi = 0
|
||||
const result = []
|
||||
for (const sample of dta) {
|
||||
try {
|
||||
const fileName = join('src', 'markdown', '__tests__', 'markdowns', `${i}${sample.repoName}`, sample.markdownSource)
|
||||
|
||||
const json = parseMessageMarkdown(sample.markdown, refUrl, imageUrl)
|
||||
|
||||
const html = jsonToHTML(json, defaultExtensions)
|
||||
|
||||
const json2 = htmlToJSON(html, defaultExtensions)
|
||||
const newMarkdown = serializeMessage(json2, refUrl, imageUrl)
|
||||
const k = calcSørensenDiceCoefficient(sample.markdown, newMarkdown) * 100
|
||||
minK = Math.min(k, minK)
|
||||
if (minK === k) {
|
||||
minKi = i
|
||||
}
|
||||
const equals = isMarkdownsEquals(sample.markdown, newMarkdown)
|
||||
if (k < 50) {
|
||||
if (!existsSync(dirname(fileName))) {
|
||||
mkdirSync(dirname(fileName), { recursive: true })
|
||||
}
|
||||
writeFileSync(fileName + '_source.md', sample.markdown)
|
||||
writeFileSync(fileName + '_target.md', newMarkdown)
|
||||
}
|
||||
result.push(`${fileName}: ${k} ${equals}`)
|
||||
} catch (e) {
|
||||
errors++
|
||||
// console.error(e)
|
||||
}
|
||||
i++
|
||||
}
|
||||
console.log('Result', result.join('\n'))
|
||||
console.log('MinK:', minK, minKi)
|
||||
console.log('Errors:', errors)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,964 @@
|
||||
//
|
||||
// Copyright © 2020 Anticrm Platform Contributors.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
/* eslint-env jest */
|
||||
import {
|
||||
MarkupMarkType,
|
||||
MarkupNode,
|
||||
MarkupNodeType,
|
||||
jsonToMarkup,
|
||||
MarkdownState,
|
||||
traverseAllMarks,
|
||||
traverseMarkupNode
|
||||
} from '@hcengineering/text'
|
||||
import { markdownToMarkup, markupToMarkdown, parseMessageMarkdown, serializeMessage } from '..'
|
||||
|
||||
describe('server', () => {
|
||||
it('todos serialize and back', async () => {
|
||||
const markdown = `# Contribution checklist
|
||||
|
||||
## Brief description
|
||||
|
||||
## Checklist
|
||||
|
||||
* [ ] - Are screenshots added to PR if applicable?
|
||||
* [x] - Does the code work as expected and all the requirements in the task are covered?
|
||||
* [x] - Are all new user-facing texts added through the translations mechanism?
|
||||
* [x] - Are all of the requirements in the task well tested?
|
||||
* [x] - Tested in Chrome?
|
||||
* [x] - Tested in Safari?
|
||||
* [x] - Have you checked the new code for typos, TODOs, commented LOCs, debug code, etc.?
|
||||
* [x] - Ensure your branch is up to date with the \`main\` branch
|
||||
* [x] - Is there any redundant or duplicate code?
|
||||
* [x] - Are required links added to PR?
|
||||
* [x] - Is the new code well documented?
|
||||
|
||||
## Related issues
|
||||
|
||||
A list of closed updated issues`
|
||||
const markup = markdownToMarkup(markdown)
|
||||
const markdownAgain = await markupToMarkdown(markup)
|
||||
expect(markdownAgain).toBe(markdown)
|
||||
})
|
||||
|
||||
it('html to markdown with links', async () => {
|
||||
const data = jsonToMarkup({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'As a part of the Platform it would be nice to have an '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'effect',
|
||||
marks: [{ type: MarkupMarkType.code, attrs: {} }]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' subsystem. Something similar to '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'https://www.effect.website',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.link,
|
||||
attrs: {
|
||||
href: 'https://www.effect.website'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const markDownData = await markupToMarkdown(data)
|
||||
expect(markDownData).toEqual(
|
||||
'As a part of the Platform it would be nice to have an `effect` subsystem. Something similar to <https://www.effect.website>'
|
||||
)
|
||||
})
|
||||
|
||||
it('html to markdown with links-2', async () => {
|
||||
const data = jsonToMarkup({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'As a part of the Platform it would be nice to have an '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'effect',
|
||||
marks: [{ type: MarkupMarkType.code, attrs: {} }]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' subsystem. Something similar to '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Effects',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.link,
|
||||
attrs: {
|
||||
href: 'https://www.effect.website'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const markDownData = await markupToMarkdown(data)
|
||||
expect(markDownData).toEqual(
|
||||
'As a part of the Platform it would be nice to have an `effect` subsystem. Something similar to [Effects](https://www.effect.website)'
|
||||
)
|
||||
})
|
||||
|
||||
it('markup to markdown', async () => {
|
||||
const data = jsonToMarkup({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.reference,
|
||||
attrs: {
|
||||
id: '629d8b615bdc96430ced15a0',
|
||||
objectclass: 'contact:class:Person',
|
||||
label: 'Sobolev Andrey'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.reference,
|
||||
attrs: {
|
||||
id: '64db123e602161ac4482475c',
|
||||
objectclass: 'github:class:GithubPullRequest',
|
||||
label: 'UBERF-16'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' link test'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const data2 =
|
||||
'[Sobolev Andrey](https://front.hc.engineering/open/?workspace=workspace&_class=contact%3Aclass%3APerson&_id=629d8b615bdc96430ced15a0&label=Sobolev%20Andrey) [UBERF-16](https://front.hc.engineering/open/?workspace=workspace&_class=github%3Aclass%3AGithubPullRequest&_id=64db123e602161ac4482475c&label=UBERF-16) link test'
|
||||
const html = await markupToMarkdown(data, 'https://front.hc.engineering/open/?workspace=workspace')
|
||||
expect(html).toEqual(data2)
|
||||
})
|
||||
|
||||
it('markdown to markup', () => {
|
||||
const data =
|
||||
'[Sobolev Andrey](https://front.hc.engineering/open/?workspace=workspace&_class=contact%3Aclass%3APerson&_id=629d8b615bdc96430ced15a0&label=Sobolev%20Andrey) [UBERF-16](https://front.hc.engineering/open/?workspace=workspace&_class=github%3Aclass%3AGithubPullRequest&_id=64db123e602161ac4482475c&label=UBERF-16) link test'
|
||||
const data2 = jsonToMarkup({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.reference,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Sobolev Andrey',
|
||||
marks: []
|
||||
}
|
||||
],
|
||||
attrs: {
|
||||
label: 'Sobolev Andrey',
|
||||
id: '629d8b615bdc96430ced15a0',
|
||||
objectclass: 'contact:class:Person'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' ',
|
||||
marks: []
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.reference,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'UBERF-16',
|
||||
marks: []
|
||||
}
|
||||
],
|
||||
attrs: {
|
||||
label: 'UBERF-16',
|
||||
id: '64db123e602161ac4482475c',
|
||||
objectclass: 'github:class:GithubPullRequest'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' link test',
|
||||
marks: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const markup = markdownToMarkup(data, 'https://front.hc.engineering/open/?workspace=workspace')
|
||||
expect(markup).toEqual(data2)
|
||||
})
|
||||
|
||||
it('test conversion', async () => {
|
||||
const data = jsonToMarkup({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Yahoo',
|
||||
marks: [{ type: MarkupMarkType.bold, attrs: {} }]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: ' something is strange'
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.hard_break
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'some more line 2'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.heading,
|
||||
attrs: {
|
||||
level: 2
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Header'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Test 3'
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.hard_break
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.hard_break
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.bullet_list,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.list_item,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'qeqwe'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.list_item,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'qwewqe'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.paragraph
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.taskList,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.taskItem,
|
||||
attrs: {
|
||||
checked: 'false'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'qweqwewq'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const markdown = await markupToMarkdown(data, 'https://front.hc.engineering/open/?workspace=workspace')
|
||||
const markup = markdownToMarkup(markdown, 'https://front.hc.engineering/open/?workspace=workspace')
|
||||
console.log(markdown, markup)
|
||||
})
|
||||
|
||||
it('Check parsing header', () => {
|
||||
const t1 = '# This is header'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
it('Check parsing bullets', () => {
|
||||
const t1 = '* Section A\n Some text\n* Section B\n Some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
it('Check parsing bullets-2', () => {
|
||||
const t1 = '* Section A\n* Some section2'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('* Section A\n* Some section2')
|
||||
})
|
||||
|
||||
it('Check ordered list', () => {
|
||||
const t1 = '1. Section A\n Some text\n2. Section B\n Some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.ordered_list)
|
||||
expect(msg.content?.[0].content?.[0].type).toEqual(MarkupNodeType.list_item)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check styles', () => {
|
||||
const t1 = '**BOLD _ITALIC_ BOLD**'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('**BOLD *ITALIC* BOLD**')
|
||||
})
|
||||
|
||||
it('Check styles-2', () => {
|
||||
const t1 = '**BOLD *ITALIC* BOLD**'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check styles-3', () => {
|
||||
const t1 = 'Some *EM **MORE EM***'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.paragraph)
|
||||
expect(msg.content?.[0].content?.length).toEqual(3)
|
||||
expect(msg.content?.[0].content?.[2]?.marks?.length).toEqual(2)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check hardbreaks', () => {
|
||||
const t1 = 'foo\\\nbaz'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[0].type).toEqual(MarkupNodeType.text)
|
||||
expect(msg.content?.[0].content?.[1].type).toEqual(MarkupNodeType.hard_break)
|
||||
expect(msg.content?.[0].content?.[2].type).toEqual(MarkupNodeType.text)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('foo\\\nbaz')
|
||||
})
|
||||
|
||||
it('Check hardbreaks with spaces', () => {
|
||||
const t1 = 'foo \nbaz'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[0].type).toEqual(MarkupNodeType.text)
|
||||
expect(msg.content?.[0].content?.[1].type).toEqual(MarkupNodeType.hard_break)
|
||||
expect(msg.content?.[0].content?.[2].type).toEqual(MarkupNodeType.text)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('foo\\\nbaz')
|
||||
})
|
||||
|
||||
it('Check softbreaks', () => {
|
||||
const t1 = 'foo\nbaz'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[0].type).toEqual(MarkupNodeType.text)
|
||||
expect(msg.content?.[0].content?.[0].text).toEqual('foo\nbaz')
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('foo\nbaz')
|
||||
})
|
||||
|
||||
it('Check softbreaks with spaces', () => {
|
||||
const t1 = 'foo \n baz'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[0].type).toEqual(MarkupNodeType.text)
|
||||
expect(msg.content?.[0].content?.[0].text).toEqual('foo\nbaz')
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('foo\nbaz')
|
||||
})
|
||||
|
||||
it('Check images', () => {
|
||||
const t1 = 'Some text\nsome text  Some text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[1].type).toEqual(MarkupNodeType.image)
|
||||
expect(msg.content?.[0].content?.[1].attrs?.src).toEqual('http://url/a.png')
|
||||
expect(msg.content?.[0].content?.[1].attrs?.title).toEqual('This is title')
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check block quote', () => {
|
||||
const t1 = '> Some quoted text\nand some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.blockquote)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('> Some quoted text\n> and some more text')
|
||||
})
|
||||
|
||||
it('Check block quote-2', () => {
|
||||
const t1 = '> Some quoted text\n> and some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.blockquote)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
it('Check block quote-3', () => {
|
||||
const t1 = '> Some quoted text\n\nand some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.blockquote)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check code block', () => {
|
||||
const t1 = "```\n# code block\nprint '3 backticks or'\nprint 'indent 4 spaces'\n```"
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.code_block)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check inline block', () => {
|
||||
const t1 = 'Hello `Some code` block'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.paragraph)
|
||||
expect(msg.content?.[0].content?.length).toEqual(3)
|
||||
expect(msg.content?.[0].content?.[1].marks?.[0].type).toEqual(MarkupMarkType.code)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check underline heading rule', () => {
|
||||
const t1 = 'Hello\n---\nSome text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('## Hello\n\nSome text')
|
||||
})
|
||||
|
||||
it('Check horizontal line', () => {
|
||||
const t1 = 'Hello\n\n---\n\nSome text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.length).toEqual(3)
|
||||
expect(msg.content?.[1].type).toEqual(MarkupNodeType.horizontal_rule)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check big inline block', () => {
|
||||
const t1 = 'Hello ```Some code``` block'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.paragraph)
|
||||
expect(msg.content?.[0].content?.length).toEqual(3)
|
||||
expect(msg.content?.[0].content?.[1].marks?.[0].type).toEqual(MarkupMarkType.code)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual('Hello `Some code` block')
|
||||
})
|
||||
it('Check code block language', () => {
|
||||
const t1 = "```typescript\n# code block\nprint '3 backticks or'\nprint 'indent 4 spaces'\n```"
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].type).toEqual(MarkupNodeType.code_block)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check link', () => {
|
||||
const t1 = 'Some text [Link Alt](http://a.com) some more text'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
it('Check link bold', () => {
|
||||
const t1 = '**[link](foo) is bold**"'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.[1].marks?.[0]?.type).toEqual(MarkupMarkType.bold)
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
|
||||
it('Check overlapping inline marks', () => {
|
||||
const t1 = 'This is **strong *emphasized text with `code` in* it**'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.length).toEqual(6)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
|
||||
expect(md).toEqual(t1)
|
||||
})
|
||||
it('Check emph url', () => {
|
||||
const t1 = 'Link to *<https://hardware.it>*'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg.type).toEqual(MarkupNodeType.doc)
|
||||
expect(msg.content?.[0].content?.length).toEqual(2)
|
||||
|
||||
const md = serializeMessage(msg, 'ref://', 'http://')
|
||||
expect(md).toEqual('Link to *<https://hardware.it>*')
|
||||
})
|
||||
it('check header hard_break serialize', () => {
|
||||
const doc: MarkupNode = {
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: '# Hello'
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.hard_break
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'World'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const md = serializeMessage(doc, 'ref://', 'http://')
|
||||
expect(md).toEqual('# Hello\\\nWorld')
|
||||
})
|
||||
it('Check inline html - 1', () => {
|
||||
const t1 = '<div><a href="bar">*foo*</a></div>'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg).toEqual({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: '*foo*',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.link,
|
||||
attrs: {
|
||||
href: 'bar',
|
||||
class: 'cursor-pointer',
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
it('Check inline html - 2', () => {
|
||||
const t1 = '<h1>hello</h1>\n<h2>world</h2>'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg).toEqual({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.heading,
|
||||
attrs: { level: 1 },
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'hello'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.heading,
|
||||
attrs: { level: 2 },
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'world'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('Check inline html - 3', () => {
|
||||
const t1 = '* line 1\n* line 2\n <img width="320" src="http://example.com/image" alt="image">'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
expect(msg).toEqual({
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.bullet_list,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.list_item,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'line 1',
|
||||
marks: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.list_item,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'line 2\n',
|
||||
marks: []
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.image,
|
||||
attrs: {
|
||||
src: 'http://example.com/image',
|
||||
alt: 'image',
|
||||
width: 320,
|
||||
align: null,
|
||||
height: null,
|
||||
title: null,
|
||||
'file-id': null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('Check traverse', () => {
|
||||
const t1 = '**[link](foo) is bold**"'
|
||||
const msg = parseMessageMarkdown(t1, 'ref://', 'http://', 'http://')
|
||||
|
||||
const nodes = []
|
||||
traverseMarkupNode(msg, (node) => {
|
||||
nodes.push(node)
|
||||
})
|
||||
expect(nodes.length).toEqual(5)
|
||||
const marks = []
|
||||
traverseAllMarks(msg, (node, mark) => {
|
||||
marks.push(mark)
|
||||
})
|
||||
expect(marks.length).toEqual(3)
|
||||
})
|
||||
|
||||
it('check serialize variant', () => {
|
||||
const node: MarkupNode = {
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: 'test1 ', type: MarkupNodeType.text },
|
||||
{ text: undefined, type: MarkupNodeType.hard_break },
|
||||
{
|
||||
text: 'Italic',
|
||||
marks: [{ type: MarkupMarkType.em, attrs: [] }],
|
||||
type: MarkupNodeType.text
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.paragraph
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.list_item
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: 'test2 ', type: MarkupNodeType.text },
|
||||
{ text: 'BOLD', marks: [{ type: MarkupMarkType.bold, attrs: [] }], type: MarkupNodeType.text }
|
||||
],
|
||||
type: MarkupNodeType.paragraph
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.list_item
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.bullet_list
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.doc
|
||||
}
|
||||
const msg = serializeMessage(node, 'ref://', 'http://')
|
||||
|
||||
expect(msg).toEqual('* test1 \\\n *Italic*\n* test2 **BOLD**')
|
||||
})
|
||||
it('check serialize throw unsupported', () => {
|
||||
const node: MarkupNode = {
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{ text: 'test1 ', type: MarkupNodeType.text },
|
||||
{ text: undefined, type: MarkupNodeType.text },
|
||||
{ text: undefined, type: (MarkupNodeType.text + 'qwe') as MarkupNodeType },
|
||||
{ text: undefined, type: MarkupNodeType.text }
|
||||
],
|
||||
type: MarkupNodeType.paragraph
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.list_item
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.bullet_list
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.doc
|
||||
}
|
||||
expect(() => serializeMessage(node, 'ref://', 'http://')).toThrowError(
|
||||
'Token type `textqwe` not supported by Markdown renderer'
|
||||
)
|
||||
})
|
||||
|
||||
it('check markdown state', () => {
|
||||
const st = new MarkdownState()
|
||||
|
||||
st.text('qwe', true)
|
||||
expect(st.out).toEqual('qwe')
|
||||
})
|
||||
|
||||
it('check markdown state', () => {
|
||||
const st = new MarkdownState()
|
||||
|
||||
const o1 = st.quote("qwe'")
|
||||
const o2 = st.quote('qwe"')
|
||||
expect(o1).toEqual('"qwe\'"')
|
||||
expect(o2).toEqual("'qwe\"'")
|
||||
})
|
||||
|
||||
it('check horizontal rule', () => {
|
||||
const node: MarkupNode = {
|
||||
content: [
|
||||
{
|
||||
attrs: {},
|
||||
type: MarkupNodeType.horizontal_rule
|
||||
}
|
||||
],
|
||||
type: MarkupNodeType.doc
|
||||
}
|
||||
expect(serializeMessage(node, 'ref://', 'http://')).toEqual('---')
|
||||
})
|
||||
|
||||
it('check code_text', () => {
|
||||
const node: MarkupNode = {
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Link to '
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'https://hardware.it',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.em,
|
||||
attrs: {}
|
||||
},
|
||||
{
|
||||
type: MarkupMarkType.link,
|
||||
attrs: {
|
||||
title: 'Some title',
|
||||
href: 'https://hardware.it'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(serializeMessage(node, 'ref://', 'http://')).toEqual(
|
||||
'Link to *[https://hardware.it](https://hardware.it "Some title")*'
|
||||
)
|
||||
})
|
||||
|
||||
it('check swithc marks', () => {
|
||||
const node: MarkupNode = {
|
||||
type: MarkupNodeType.doc,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.paragraph,
|
||||
content: [
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'Link to ',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.bold,
|
||||
attrs: {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: MarkupNodeType.text,
|
||||
text: 'https://hardware.it',
|
||||
marks: [
|
||||
{
|
||||
type: MarkupMarkType.em,
|
||||
attrs: {}
|
||||
},
|
||||
{
|
||||
type: MarkupMarkType.bold,
|
||||
attrs: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(serializeMessage(node, 'ref://', 'http://')).toEqual('**Link to *https://hardware.it***')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ServerKit } from '@hcengineering/text'
|
||||
|
||||
import { AnyExtension, Extension, Node, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
export interface SubLinkOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
hasHulyText: (text: string) => boolean
|
||||
hasHulyLink: (href: string) => boolean
|
||||
}
|
||||
|
||||
export const SubLink = Node.create<SubLinkOptions>({
|
||||
name: 'subLink',
|
||||
|
||||
addOptions () {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
hasHulyText: (text: string) => false,
|
||||
hasHulyLink: (href: string) => false
|
||||
}
|
||||
},
|
||||
|
||||
group: 'block',
|
||||
|
||||
content: 'inline*',
|
||||
|
||||
parseHTML () {
|
||||
// this plugin contains special parse rule that matches DOM element only when:
|
||||
// - it has a special inner text
|
||||
// - or it has a special link
|
||||
// When no match, the element won't be parsed as sub node but will be processed by other extensions
|
||||
return [
|
||||
{
|
||||
tag: 'sub',
|
||||
getAttrs: (el: HTMLElement | string) => {
|
||||
if (typeof el !== 'string') {
|
||||
if (this.options.hasHulyText(el.textContent ?? '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const link = el.querySelector('a[href]')
|
||||
const href = link?.getAttribute('href') ?? ''
|
||||
if (link != null && this.options.hasHulyLink(href)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// no match
|
||||
return false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML ({ HTMLAttributes }) {
|
||||
return ['sub', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
}
|
||||
})
|
||||
|
||||
export interface GithubKitOptions {
|
||||
sub?: Partial<SubLinkOptions> | false
|
||||
}
|
||||
|
||||
export const GithubKit = Extension.create<GithubKitOptions>({
|
||||
name: 'githubKit',
|
||||
|
||||
addExtensions () {
|
||||
return [
|
||||
ServerKit.configure({
|
||||
image: {
|
||||
getBlobRef: async () => ({ src: '', srcset: '' })
|
||||
}
|
||||
}),
|
||||
...(this.options.sub !== false ? [SubLink.configure({ ...this.options.sub })] : [])
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export const defaultExtensions: AnyExtension[] = [GithubKit.configure({})]
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import {
|
||||
MarkupNode,
|
||||
jsonToMarkup,
|
||||
markupToJSON,
|
||||
MarkdownParser,
|
||||
storeNodes,
|
||||
storeMarks,
|
||||
MarkdownState
|
||||
} from '@hcengineering/text'
|
||||
import { GithubKit } from './extensions'
|
||||
import { hasHulyLink, hasHulyLinkText } from '../sync/guest'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function parseMessageMarkdown (
|
||||
message: string | undefined,
|
||||
refUrl: string,
|
||||
imageUrl: string,
|
||||
guestUrl: string
|
||||
): MarkupNode {
|
||||
const extensions = [
|
||||
GithubKit.configure({
|
||||
sub: {
|
||||
hasHulyText: hasHulyLinkText,
|
||||
hasHulyLink: (href) => hasHulyLink(href, guestUrl)
|
||||
}
|
||||
})
|
||||
]
|
||||
const parser = new MarkdownParser(extensions, refUrl, imageUrl)
|
||||
return parser.parse(message ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function serializeMessage (node: MarkupNode, refUrl: string, imageUrl: string): string {
|
||||
const state = new MarkdownState(storeNodes, storeMarks, { tightLists: true, refUrl, imageUrl })
|
||||
state.renderContent(node)
|
||||
return state.out
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function markupToMarkdown (
|
||||
markup: string,
|
||||
refUrl: string = 'ref://',
|
||||
imageUrl: string = 'http://localhost',
|
||||
preprocessor?: (nodes: MarkupNode) => Promise<void>
|
||||
): Promise<string> {
|
||||
const json = markupToJSON(markup)
|
||||
await preprocessor?.(json)
|
||||
return serializeMessage(json, refUrl, imageUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function markdownToMarkup (
|
||||
message: string,
|
||||
refUrl: string = 'ref://',
|
||||
imageUrl: string = 'http://localhost',
|
||||
guestUrl: string = 'http://localhost/guest'
|
||||
): string {
|
||||
const json = parseMessageMarkdown(message, refUrl, imageUrl, guestUrl)
|
||||
return jsonToMarkup(json)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Account, Doc, Ref, TxOperations } from '@hcengineering/core'
|
||||
import notification, { DocNotifyContext } from '@hcengineering/notification'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import github from '@hcengineering/github'
|
||||
|
||||
export async function createNotification (
|
||||
client: TxOperations,
|
||||
forDoc: Doc,
|
||||
data: { user: Ref<Account>, message: IntlString, props: Record<string, any> }
|
||||
): Promise<void> {
|
||||
let docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { attachedTo: forDoc._id })
|
||||
|
||||
if (docNotifyContext?._id === undefined) {
|
||||
const docNotifyContextId = await client.createDoc(notification.class.DocNotifyContext, forDoc.space, {
|
||||
attachedTo: forDoc._id,
|
||||
attachedToClass: forDoc._class,
|
||||
hidden: false,
|
||||
user: data.user,
|
||||
isPinned: false
|
||||
})
|
||||
docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { _id: docNotifyContextId })
|
||||
}
|
||||
|
||||
// Check if we had already same notification send, and just unmark it viewed.
|
||||
|
||||
const existing = await client.findOne(notification.class.CommonInboxNotification, {
|
||||
user: data.user,
|
||||
message: data.message,
|
||||
props: data.props
|
||||
})
|
||||
if (existing !== undefined) {
|
||||
await client.update(docNotifyContext as DocNotifyContext, {
|
||||
lastUpdateTimestamp: Date.now()
|
||||
})
|
||||
await client.update(existing, {
|
||||
isViewed: false
|
||||
})
|
||||
} else {
|
||||
await client.createDoc(notification.class.CommonInboxNotification, forDoc.space, {
|
||||
user: data.user,
|
||||
icon: github.icon.Github,
|
||||
message: data.message,
|
||||
props: data.props,
|
||||
isViewed: false,
|
||||
docNotifyContext: docNotifyContext?._id as Ref<DocNotifyContext>
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
//
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
//
|
||||
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import core, {
|
||||
Account,
|
||||
BrandingMap,
|
||||
Client,
|
||||
ClientConnectEvent,
|
||||
DocumentUpdate,
|
||||
MeasureContext,
|
||||
RateLimiter,
|
||||
Ref,
|
||||
TxOperations
|
||||
} from '@hcengineering/core'
|
||||
import github, { GithubAuthentication, GithubIntegration, makeQuery } from '@hcengineering/github'
|
||||
import { MongoClientReference, getMongoClient } from '@hcengineering/mongo'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
|
||||
import serverToken, { generateToken } from '@hcengineering/server-token'
|
||||
import tracker from '@hcengineering/tracker'
|
||||
import { Installation } from '@octokit/webhooks-types'
|
||||
import { Collection } from 'mongodb'
|
||||
import { App, Octokit } from 'octokit'
|
||||
|
||||
import { ClientWorkspaceInfo } from '@hcengineering/account'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { SplitLogger } from '@hcengineering/analytics-service'
|
||||
import contact, { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import { type StorageAdapter } from '@hcengineering/server-core'
|
||||
import { join } from 'path'
|
||||
import { getWorkspaceInfo } from './account'
|
||||
import { createPlatformClient } from './client'
|
||||
import config from './config'
|
||||
import { registerLoaders } from './loaders'
|
||||
import { createNotification } from './notifications'
|
||||
import { errorToObj } from './sync/utils'
|
||||
import { GithubIntegrationRecord, GithubUserRecord } from './types'
|
||||
import { GithubWorker, syncUser } from './worker'
|
||||
|
||||
export interface InstallationRecord {
|
||||
installationName: string
|
||||
login: string
|
||||
loginNodeId: string
|
||||
type: 'Bot' | 'User' | 'Organization'
|
||||
octokit: Octokit
|
||||
}
|
||||
|
||||
export class PlatformWorker {
|
||||
private readonly clients: Map<string, GithubWorker> = new Map<string, GithubWorker>()
|
||||
|
||||
storageAdapter!: StorageAdapter
|
||||
|
||||
installations = new Map<number, InstallationRecord>()
|
||||
|
||||
integrations: GithubIntegrationRecord[] = []
|
||||
|
||||
mongoRef!: MongoClientReference
|
||||
|
||||
integrationCollection!: Collection<GithubIntegrationRecord>
|
||||
usersCollection!: Collection<GithubUserRecord>
|
||||
periodicTimer: any
|
||||
periodicSyncPromise: Promise<void> | undefined
|
||||
|
||||
canceled = false
|
||||
|
||||
private constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly app: App,
|
||||
readonly brandingMap: BrandingMap,
|
||||
readonly periodicSyncInterval = 10 * 60 * 1000 // 10 minutes
|
||||
) {
|
||||
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
|
||||
registerLoaders()
|
||||
}
|
||||
|
||||
public async initStorage (): Promise<void> {
|
||||
this.mongoRef = getMongoClient(config.MongoURL)
|
||||
const mongoClient = await this.mongoRef.getClient()
|
||||
|
||||
const db = mongoClient.db(config.ConfigurationDB)
|
||||
this.integrationCollection = db.collection<GithubIntegrationRecord>('installations')
|
||||
this.usersCollection = db.collection<GithubUserRecord>('users')
|
||||
|
||||
const storageConfig = storageConfigFromEnv()
|
||||
this.storageAdapter = buildStorageFromConfig(storageConfig, config.MongoURL)
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
this.canceled = true
|
||||
await Promise.all(
|
||||
[...this.clients.values()].map(async (worker) => {
|
||||
await worker.close()
|
||||
})
|
||||
)
|
||||
this.clients.clear()
|
||||
await this.storageAdapter.close()
|
||||
this.mongoRef.close()
|
||||
}
|
||||
|
||||
async init (ctx: MeasureContext): Promise<void> {
|
||||
this.integrations = await this.integrationCollection.find({}).toArray()
|
||||
await this.queryInstallations(ctx)
|
||||
|
||||
const workspacesToCheck = new Set<string>()
|
||||
// We need to delete local integrations not retrieved by queryInstallations()
|
||||
for (const intValue of this.integrations) {
|
||||
workspacesToCheck.add(intValue.workspace)
|
||||
}
|
||||
for (const integr of [...this.integrations]) {
|
||||
// We need to check and remove integrations without a real integration's
|
||||
if (!this.installations.has(integr.installationId)) {
|
||||
ctx.warn('Installation was deleted during service shutdown', {
|
||||
installationId: integr.installationId,
|
||||
workspace: integr.workspace
|
||||
})
|
||||
this.integrations = this.integrations.filter((it) => it.installationId !== integr.installationId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspace of workspacesToCheck) {
|
||||
// We need to connect to workspace and verify all installations and clean if required
|
||||
try {
|
||||
ctx.info('check clean', { workspace })
|
||||
await this.cleanWorkspaceInstallations(ctx, workspace)
|
||||
} catch (err: any) {
|
||||
ctx.error('failed to clean workspace', { err, workspace })
|
||||
}
|
||||
}
|
||||
|
||||
void this.doSyncWorkspaces().catch((err) => {
|
||||
ctx.error('error during sync workspaces', { err })
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
this.periodicTimer = setInterval(() => {
|
||||
if (this.periodicSyncPromise === undefined) {
|
||||
this.periodicSyncPromise = this.performPeriodicSync()
|
||||
}
|
||||
}, this.periodicSyncInterval)
|
||||
}
|
||||
|
||||
async performPeriodicSync (): Promise<void> {
|
||||
// Sync authorized users information details.
|
||||
const workspaces = await this.findUsersWorkspaces()
|
||||
for (const [workspace, users] of workspaces) {
|
||||
const worker = this.clients.get(workspace)
|
||||
if (worker !== undefined) {
|
||||
await this.ctx.with('syncUsers', {}, async (ctx) => {
|
||||
await worker.syncUserData(ctx, users)
|
||||
})
|
||||
}
|
||||
}
|
||||
this.periodicSyncPromise = undefined
|
||||
}
|
||||
|
||||
triggerCheckWorkspaces = (): void => {}
|
||||
|
||||
async doSyncWorkspaces (): Promise<void> {
|
||||
while (!this.canceled) {
|
||||
let errors = false
|
||||
try {
|
||||
errors = await this.checkWorkspaces()
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
this.ctx.error('check workspace', err)
|
||||
errors = true
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.triggerCheckWorkspaces = resolve
|
||||
if (errors) {
|
||||
setTimeout(resolve, 5000)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async findUsersWorkspaces (): Promise<Map<string, GithubUserRecord[]>> {
|
||||
const i = this.usersCollection.find({})
|
||||
const workspaces = new Map<string, GithubUserRecord[]>()
|
||||
while (await i.hasNext()) {
|
||||
const userInfo = await i.next()
|
||||
if (userInfo !== null) {
|
||||
for (const ws of Object.keys(userInfo.accounts ?? {})) {
|
||||
if (this.integrations.find((it) => it.workspace === ws) === undefined) {
|
||||
// No workspace integration found, let's check workspace.
|
||||
workspaces.set(ws, [...(workspaces.get(ws) ?? []), userInfo])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return workspaces
|
||||
}
|
||||
|
||||
public async getUsers (workspace: string): Promise<GithubUserRecord[]> {
|
||||
return await this.usersCollection
|
||||
.find<GithubUserRecord>({
|
||||
[`accounts.${workspace}`]: { $exists: true }
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
public async getUser (login: string): Promise<GithubUserRecord | undefined> {
|
||||
return (await this.usersCollection.find<GithubUserRecord>({ _id: login }).toArray()).shift()
|
||||
}
|
||||
|
||||
async cleanWorkspaceInstallations (ctx: MeasureContext, workspace: string, installId?: number): Promise<void> {
|
||||
// TODO: Do not remove record from $github if we failed to clean github installations inside workspace.
|
||||
const token = generateToken(
|
||||
config.SystemEmail,
|
||||
{
|
||||
name: workspace,
|
||||
productId: config.ProductID
|
||||
},
|
||||
{ mode: 'github' }
|
||||
)
|
||||
let workspaceInfo: ClientWorkspaceInfo
|
||||
try {
|
||||
workspaceInfo = await getWorkspaceInfo(token)
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Workspace not found:', { workspace })
|
||||
return
|
||||
}
|
||||
if (workspaceInfo === undefined) {
|
||||
ctx.error('No workspace found', { workspace })
|
||||
return
|
||||
}
|
||||
let client: Client | undefined
|
||||
try {
|
||||
client = await createPlatformClient(workspace, config.ProductID, 10000)
|
||||
const ops = new TxOperations(client, core.account.System)
|
||||
|
||||
const wsIntegerations = await client.findAll(github.class.GithubIntegration, {})
|
||||
|
||||
for (const intValue of wsIntegerations) {
|
||||
if (!this.installations.has(intValue.installationId) || intValue.installationId === installId) {
|
||||
await ops.remove<GithubIntegration>(intValue)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client?.close()
|
||||
}
|
||||
}
|
||||
|
||||
async mapInstallation (
|
||||
ctx: MeasureContext,
|
||||
workspace: string,
|
||||
installationId: number,
|
||||
accountId: Ref<Account>
|
||||
): Promise<void> {
|
||||
if (this.integrations.find((it) => it.installationId === installationId) != null) {
|
||||
// What to do with installation in different workspace?
|
||||
// Let's remove it and sync to new one.
|
||||
const worker = this.clients.get(workspace) as GithubWorker
|
||||
await worker?.reloadRepositories(installationId)
|
||||
worker?.triggerUpdate()
|
||||
|
||||
this.triggerCheckWorkspaces()
|
||||
return
|
||||
}
|
||||
const record: GithubIntegrationRecord = {
|
||||
workspace,
|
||||
installationId,
|
||||
accountId
|
||||
}
|
||||
await ctx.withLog('add integration', { workspace, installationId, accountId }, async (ctx) => {
|
||||
await this.integrationCollection.insertOne(record)
|
||||
this.integrations.push(record)
|
||||
})
|
||||
// We need to query installations to be sure we have it, in case event is delayed or not received.
|
||||
await this.updateInstallation(installationId)
|
||||
|
||||
const worker = this.clients.get(workspace) as GithubWorker
|
||||
await worker?.reloadRepositories(installationId)
|
||||
worker?.triggerUpdate()
|
||||
|
||||
this.triggerCheckWorkspaces()
|
||||
}
|
||||
|
||||
async removeInstallation (ctx: MeasureContext, workspace: string, installationId: number): Promise<void> {
|
||||
const installation = this.installations.get(installationId)
|
||||
if (installation !== undefined) {
|
||||
await installation.octokit.rest.apps.deleteInstallation({
|
||||
installation_id: installationId
|
||||
})
|
||||
}
|
||||
// Clean workspace
|
||||
await this.cleanWorkspaceInstallations(ctx, workspace, installationId)
|
||||
this.triggerCheckWorkspaces()
|
||||
}
|
||||
|
||||
async requestGithubAccessToken (payload: {
|
||||
workspace: string
|
||||
code: string
|
||||
state: string
|
||||
accountId: Ref<Account>
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const uri =
|
||||
'https://github.com/login/oauth/access_token?' +
|
||||
makeQuery({
|
||||
client_id: config.ClientID,
|
||||
client_secret: config.ClientSecret,
|
||||
code: payload.code,
|
||||
state: payload.state
|
||||
})
|
||||
const result = await fetch(uri, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const resultJson = await result.json()
|
||||
if (resultJson.error !== undefined) {
|
||||
await this.updateAccountAuthRecord(payload, { error: null }, undefined, false)
|
||||
} else {
|
||||
const okit = new Octokit({
|
||||
auth: resultJson.access_token,
|
||||
client_id: config.ClientID,
|
||||
client_secret: config.ClientSecret
|
||||
})
|
||||
const user = await okit.rest.users.getAuthenticated()
|
||||
const nowTime = Date.now() / 1000
|
||||
const dta: GithubUserRecord = {
|
||||
_id: user.data.login,
|
||||
token: resultJson.access_token,
|
||||
code: null,
|
||||
expiresIn: resultJson.expires_in != null ? nowTime + (resultJson.expires_in as number) : null,
|
||||
refreshToken: resultJson.refresh_token ?? null,
|
||||
refreshTokenExpiresIn:
|
||||
resultJson.refresh_token_expires_in !== undefined
|
||||
? nowTime + (resultJson.refresh_token_expires_in as number)
|
||||
: null,
|
||||
scope: resultJson.scope,
|
||||
accounts: { [payload.workspace]: payload.accountId }
|
||||
}
|
||||
const [existingUser] = await this.usersCollection.find({ _id: user.data.login }).toArray()
|
||||
if (existingUser === undefined) {
|
||||
await this.usersCollection.insertOne(dta)
|
||||
} else {
|
||||
dta.accounts = { ...existingUser.accounts, [payload.workspace]: payload.accountId }
|
||||
await this.usersCollection.updateOne({ _id: dta._id }, { $set: dta } as any)
|
||||
}
|
||||
|
||||
// Update workspace client login info.
|
||||
await this.updateAccountAuthRecord(
|
||||
payload,
|
||||
{
|
||||
login: dta._id,
|
||||
error: null,
|
||||
avatar: user.data.avatar_url,
|
||||
name: user.data.name ?? '',
|
||||
url: user.data.url
|
||||
},
|
||||
dta,
|
||||
false
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
await this.updateAccountAuthRecord(payload, { error: errorToObj(err) }, undefined, false)
|
||||
}
|
||||
}
|
||||
|
||||
private async updateAccountAuthRecord (
|
||||
payload: { workspace: string, accountId: Ref<Account> },
|
||||
update: DocumentUpdate<GithubAuthentication>,
|
||||
dta: GithubUserRecord | undefined,
|
||||
revoke: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
let platformClient: Client | undefined
|
||||
let shouldClose = false
|
||||
try {
|
||||
platformClient = this.clients.get(payload.workspace)?.client
|
||||
if (platformClient === undefined) {
|
||||
shouldClose = true
|
||||
platformClient = await createPlatformClient(payload.workspace, config.ProductID, 30000)
|
||||
}
|
||||
const client = new TxOperations(platformClient, payload.accountId)
|
||||
|
||||
const personAuth = await client.findOne(github.class.GithubAuthentication, {
|
||||
attachedTo: payload.accountId
|
||||
})
|
||||
if (personAuth !== undefined) {
|
||||
if (revoke) {
|
||||
await client.remove(personAuth, Date.now(), payload.accountId)
|
||||
} else {
|
||||
await client.update<GithubAuthentication>(personAuth, update, false, Date.now(), payload.accountId)
|
||||
}
|
||||
}
|
||||
|
||||
// We need to re-bind previously created github:login account to a proper person.
|
||||
const account = (await client.findOne(core.class.Account, { _id: payload.accountId })) as PersonAccount
|
||||
const person = (await client.findOne(contact.class.Person, { _id: account.person })) as Person
|
||||
if (person !== undefined) {
|
||||
if (!revoke) {
|
||||
await createNotification(client, person, {
|
||||
user: account._id,
|
||||
message: github.string.AuthenticatedWithGithub,
|
||||
props: {
|
||||
login: update.login
|
||||
}
|
||||
})
|
||||
|
||||
const githubAccount = (await client.findOne(core.class.Account, {
|
||||
email: 'github:' + update.login
|
||||
})) as PersonAccount
|
||||
if (githubAccount !== undefined && githubAccount.person !== account.person) {
|
||||
const dummyPerson = githubAccount.person
|
||||
// To add activity entry to dummy person.
|
||||
await client.update(githubAccount, { person: account.person }, false, Date.now(), payload.accountId)
|
||||
|
||||
const dPerson = (await client.findOne(contact.class.Person, { _id: dummyPerson })) as Person
|
||||
if (person !== undefined && dPerson !== undefined) {
|
||||
await createNotification(client, dPerson, {
|
||||
user: account._id,
|
||||
message: github.string.AuthenticatedWithGithubEmployee,
|
||||
props: {
|
||||
login: update.login
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await createNotification(client, person, {
|
||||
user: account._id,
|
||||
message: github.string.AuthenticationRevokedGithub,
|
||||
props: {
|
||||
login: update.login
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (dta !== undefined && personAuth !== undefined) {
|
||||
try {
|
||||
await syncUser(this.ctx, dta, personAuth, client, payload.accountId)
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.message === 'Bad credentials') {
|
||||
await this.revokeUserAuth(dta)
|
||||
} else {
|
||||
this.ctx.error(`Failed to sync user ${dta._id}`, { error: errorToObj(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await platformClient?.close()
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async checkRefreshToken (auth: GithubUserRecord, force: boolean = false): Promise<void> {
|
||||
if (auth.refreshToken != null && auth.expiresIn != null && auth.expiresIn < Date.now() / 1000) {
|
||||
const uri =
|
||||
'https://github.com/login/oauth/access_token?' +
|
||||
makeQuery({
|
||||
client_id: config.ClientID,
|
||||
client_secret: config.ClientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: auth.refreshToken
|
||||
})
|
||||
|
||||
const result = await fetch(uri, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
})
|
||||
const resultJson = await result.json()
|
||||
|
||||
if (resultJson.error !== undefined) {
|
||||
// We need to clear github integration info.
|
||||
await this.revokeUserAuth(auth)
|
||||
} else {
|
||||
// Update okit
|
||||
const nowTime = Date.now() / 1000
|
||||
const dta: GithubUserRecord = {
|
||||
...auth,
|
||||
token: resultJson.access_token,
|
||||
code: null,
|
||||
expiresIn: nowTime + (resultJson.expires_in as number),
|
||||
refreshToken: resultJson.refresh_token,
|
||||
refreshTokenExpiresIn: nowTime + (resultJson.refresh_token_expires_in as number),
|
||||
scope: resultJson.scope
|
||||
}
|
||||
auth.token = resultJson.access_token
|
||||
auth.code = null
|
||||
auth.expiresIn = dta.expiresIn
|
||||
auth.refreshToken = dta.refreshToken
|
||||
auth.refreshTokenExpiresIn = dta.refreshTokenExpiresIn
|
||||
auth.scope = dta.scope
|
||||
|
||||
await this.usersCollection.updateOne({ _id: dta._id }, { $set: dta } as any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAccount (login: string): Promise<GithubUserRecord | undefined> {
|
||||
return (await this.usersCollection.findOne({ _id: login })) ?? undefined
|
||||
}
|
||||
|
||||
async getAccountByRef (workspace: string, ref: Ref<Account>): Promise<GithubUserRecord | undefined> {
|
||||
return (await this.usersCollection.findOne({ [`accounts.${workspace}`]: ref })) ?? undefined
|
||||
}
|
||||
|
||||
private async updateInstallation (installationId: number): Promise<void> {
|
||||
const install = await this.app.octokit.rest.apps.getInstallation({ installation_id: installationId })
|
||||
if (install !== null) {
|
||||
const tinst = install.data as Installation
|
||||
const val: InstallationRecord = {
|
||||
octokit: await this.app.getInstallationOctokit(installationId),
|
||||
login: tinst.account.login,
|
||||
loginNodeId: tinst.account.node_id,
|
||||
type: tinst.account?.type ?? 'User',
|
||||
installationName: `${tinst.account?.html_url ?? ''}`
|
||||
}
|
||||
this.installations.set(installationId, val)
|
||||
}
|
||||
}
|
||||
|
||||
private async queryInstallations (ctx: MeasureContext): Promise<void> {
|
||||
for await (const install of this.app.eachInstallation.iterator()) {
|
||||
const tinst = install.installation as Installation
|
||||
const val: InstallationRecord = {
|
||||
octokit: install.octokit,
|
||||
login: tinst.account.login,
|
||||
loginNodeId: tinst.account.node_id,
|
||||
type: tinst.account?.type ?? 'User',
|
||||
installationName: `${tinst.account?.html_url ?? ''}`
|
||||
}
|
||||
this.installations.set(install.installation.id, val)
|
||||
ctx.info('Found installation', {
|
||||
installationId: install.installation.id,
|
||||
url: install.installation.account?.html_url ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstallationEvent (install: Installation, enabled: boolean): Promise<void> {
|
||||
this.ctx.info('handle integration add', { installId: install.id, name: install.html_url })
|
||||
const okit = await this.app.getInstallationOctokit(install.id)
|
||||
const iName = `${install.account.html_url ?? ''}`
|
||||
this.installations.set(install.id, {
|
||||
octokit: okit,
|
||||
login: install.account.login,
|
||||
type: install.account?.type ?? 'User',
|
||||
loginNodeId: install.account.node_id,
|
||||
installationName: iName
|
||||
})
|
||||
|
||||
const worker = this.getWorker(install.id)
|
||||
if (worker !== undefined) {
|
||||
await worker.syncUserData(this.ctx, await this.getUsers(worker.workspace.name))
|
||||
await worker.reloadRepositories(install.id)
|
||||
|
||||
worker.triggerUpdate()
|
||||
worker.triggerSync()
|
||||
}
|
||||
|
||||
// Need to inform workspace
|
||||
const integeration = this.integrations.find((it) => it.installationId === install.id)
|
||||
if (integeration !== undefined) {
|
||||
const worker = this.clients.get(integeration.workspace) as GithubWorker
|
||||
worker?.triggerUpdate()
|
||||
}
|
||||
|
||||
// Check if no workspace was available
|
||||
this.triggerCheckWorkspaces()
|
||||
}
|
||||
|
||||
async handleInstallationEventDelete (installId: number): Promise<void> {
|
||||
const existing = this.installations.get(installId)
|
||||
this.installations.delete(installId)
|
||||
this.ctx.info('handle integration delete', { installId, name: existing?.installationName })
|
||||
|
||||
const interg = this.integrations.find((it) => it.installationId === installId)
|
||||
|
||||
// We already have, worker we need to update it.
|
||||
const worker = this.getWorker(installId) ?? (interg !== undefined ? this.clients.get(interg.workspace) : undefined)
|
||||
if (worker !== undefined) {
|
||||
const integeration = worker.integrations.get(installId)
|
||||
if (integeration !== undefined) {
|
||||
integeration.enabled = false
|
||||
integeration.synchronized = new Set()
|
||||
await worker._client.remove(integeration.integration)
|
||||
}
|
||||
worker.integrations.delete(installId)
|
||||
worker.triggerUpdate()
|
||||
} else {
|
||||
this.ctx.info('No worker for removed installation', { installId, name: existing?.installationName })
|
||||
// No worker
|
||||
}
|
||||
this.integrations = this.integrations.filter((it) => it.installationId !== installId)
|
||||
await this.integrationCollection.deleteOne({ installationId: installId })
|
||||
this.triggerCheckWorkspaces()
|
||||
}
|
||||
|
||||
async getWorkspaces (): Promise<string[]> {
|
||||
const workspaces = new Set(this.integrations.map((it) => it.workspace))
|
||||
|
||||
return Array.from(workspaces)
|
||||
}
|
||||
|
||||
private async checkWorkspaces (): Promise<boolean> {
|
||||
let workspaces = await this.getWorkspaces()
|
||||
if (process.env.GITHUB_USE_WS !== undefined) {
|
||||
workspaces = [process.env.GITHUB_USE_WS]
|
||||
}
|
||||
const toDelete = new Set<string>(this.clients.keys())
|
||||
|
||||
const rateLimiter = new RateLimiter(5)
|
||||
let errors = 0
|
||||
let idx = 0
|
||||
for (const workspace of workspaces) {
|
||||
const widx = ++idx
|
||||
if (this.clients.has(workspace)) {
|
||||
toDelete.delete(workspace)
|
||||
continue
|
||||
}
|
||||
await rateLimiter.add(async () => {
|
||||
const token = generateToken(
|
||||
config.SystemEmail,
|
||||
{
|
||||
name: workspace,
|
||||
productId: config.ProductID
|
||||
},
|
||||
{ mode: 'github' }
|
||||
)
|
||||
let workspaceInfo: ClientWorkspaceInfo | undefined
|
||||
try {
|
||||
workspaceInfo = await getWorkspaceInfo(token)
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Workspace not found:', { workspace })
|
||||
errors++
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (workspaceInfo?.workspace === undefined) {
|
||||
this.ctx.error('No workspace exists for workspaceId', { workspace })
|
||||
errors++
|
||||
return
|
||||
}
|
||||
const branding = Object.values(this.brandingMap).find((b) => b.key === workspaceInfo?.branding) ?? null
|
||||
const workerCtx = this.ctx.newChild('worker', { workspace: workspaceInfo.workspace }, {})
|
||||
workerCtx.info('************************* Register worker ************************* ', {
|
||||
workspaceId: workspaceInfo.workspaceId,
|
||||
workspace: workspaceInfo.workspace,
|
||||
index: widx,
|
||||
total: workspaces.length
|
||||
})
|
||||
const worker = await GithubWorker.create(
|
||||
this,
|
||||
workerCtx,
|
||||
this.installations,
|
||||
{
|
||||
name: workspace,
|
||||
productId: config.ProductID,
|
||||
workspaceUrl: workspaceInfo.workspace,
|
||||
workspaceName: workspace
|
||||
},
|
||||
branding,
|
||||
this.app,
|
||||
this.storageAdapter,
|
||||
(workspace, event) => {
|
||||
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
|
||||
void this.clients.get(workspace)?.refreshClient(event === ClientConnectEvent.Upgraded)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (worker !== undefined) {
|
||||
workerCtx.info('Register worker Done', {
|
||||
workspaceId: workspaceInfo.workspaceId,
|
||||
workspace: workspaceInfo.workspace,
|
||||
index: widx,
|
||||
total: workspaces.length
|
||||
})
|
||||
// No if no integration, we will try connect one more time in a time period
|
||||
this.clients.set(workspace, worker)
|
||||
} else {
|
||||
errors++
|
||||
}
|
||||
} catch (e: any) {
|
||||
Analytics.handleError(e)
|
||||
this.ctx.info("Couldn't create WS worker", { workspace, error: e })
|
||||
console.error(e)
|
||||
errors++
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
await rateLimiter.waitProcessing()
|
||||
} catch (e: any) {
|
||||
Analytics.handleError(e)
|
||||
errors++
|
||||
}
|
||||
// Close deleted workspaces
|
||||
for (const deleted of Array.from(toDelete.keys())) {
|
||||
const ws = this.clients.get(deleted)
|
||||
if (ws !== undefined) {
|
||||
try {
|
||||
await ws.ctx.logger.close()
|
||||
this.ctx.info('workspace removed from tracking list', { workspace: deleted })
|
||||
this.clients.delete(deleted)
|
||||
await ws.close()
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors > 0
|
||||
}
|
||||
|
||||
getWorkers (): GithubWorker[] {
|
||||
return Array.from(this.clients.values())
|
||||
}
|
||||
|
||||
static async create (ctx: MeasureContext, app: App, brandingMap: BrandingMap): Promise<PlatformWorker> {
|
||||
const worker = new PlatformWorker(ctx, app, brandingMap)
|
||||
await worker.initStorage()
|
||||
await worker.init(ctx)
|
||||
worker.initWebhooks()
|
||||
return worker
|
||||
}
|
||||
|
||||
initWebhooks (): void {
|
||||
const webhook = this.ctx.newChild(
|
||||
'webhook',
|
||||
{},
|
||||
{},
|
||||
new SplitLogger('webhook', { root: join(process.cwd(), 'logs'), pretty: true, enableConsole: false })
|
||||
)
|
||||
webhook.info('Register webhook')
|
||||
|
||||
this.app.webhooks.onAny(async (event) => {
|
||||
const shortData: Record<string, string> = { id: event.id, name: event.name }
|
||||
if ('action' in event.payload) {
|
||||
shortData.action = event.payload.action
|
||||
}
|
||||
this.ctx.info('webhook event', shortData)
|
||||
webhook.info('event', { ...shortData, payload: event.payload })
|
||||
})
|
||||
|
||||
this.app.webhooks.on('github_app_authorization', async (event) => {
|
||||
if (event.payload.action === 'revoked') {
|
||||
const sender = event.payload.sender
|
||||
|
||||
const records = await this.usersCollection.find({ _id: sender.login }).toArray()
|
||||
for (const r of records) {
|
||||
await this.revokeUserAuth(r)
|
||||
}
|
||||
await this.usersCollection.deleteOne({ _id: sender.login })
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.onError(async (event) => {
|
||||
this.ctx.error('webhook event', { message: event.message, name: event.name, cause: event.cause })
|
||||
webhook.error('event', { ...event })
|
||||
})
|
||||
|
||||
function catchEventError (
|
||||
promise: Promise<void>,
|
||||
action: string,
|
||||
name: string,
|
||||
id: string,
|
||||
repository: string
|
||||
): void {
|
||||
void promise.catch((err) => {
|
||||
webhook.error('error during handleEvent', { err, event: action, repository, name, id })
|
||||
Analytics.handleError(err)
|
||||
})
|
||||
}
|
||||
|
||||
this.app.webhooks.on('pull_request', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.class.GithubPullRequest, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.name
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.on('issues', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(tracker.class.Issue, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.name
|
||||
)
|
||||
}
|
||||
})
|
||||
this.app.webhooks.on('issue_comment', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(chunter.class.ChatMessage, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.name
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.on('repository', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.mixin.GithubProject, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.name
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.on('projects_v2_item', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
if (payload.projects_v2_item.content_type === 'Issue') {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(tracker.class.Issue, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.projects_v2_item.node_id
|
||||
)
|
||||
} else if (payload.projects_v2_item.content_type === 'PullRequest') {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.class.GithubPullRequest, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.projects_v2_item.node_id
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.on('installation', async ({ payload, name, id }) => {
|
||||
switch (payload.action) {
|
||||
case 'created':
|
||||
case 'unsuspend': {
|
||||
catchEventError(
|
||||
this.handleInstallationEvent(payload.installation, true),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.installation.html_url
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'suspend': {
|
||||
catchEventError(
|
||||
this.handleInstallationEvent(payload.installation, false),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.installation.html_url
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'deleted': {
|
||||
catchEventError(
|
||||
this.handleInstallationEventDelete(payload.installation.id),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.installation.html_url
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
this.app.webhooks.on('installation_repositories', async ({ payload, name, id }) => {
|
||||
const worker = this.getWorker(payload.installation.id)
|
||||
if (worker === undefined) {
|
||||
this.triggerCheckWorkspaces()
|
||||
return
|
||||
}
|
||||
catchEventError(
|
||||
worker.reloadRepositories(payload.installation.id),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.installation.html_url
|
||||
)
|
||||
const doSyncUsers = async (worker: GithubWorker): Promise<void> => {
|
||||
const users = await this.getUsers(worker.workspace.name)
|
||||
await worker.syncUserData(this.ctx, users)
|
||||
}
|
||||
catchEventError(doSyncUsers(worker), payload.action, name, id, payload.installation.html_url)
|
||||
})
|
||||
|
||||
this.app.webhooks.on('pull_request_review', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.class.GithubReview, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.html_url
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.app.webhooks.on('pull_request_review_comment', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.class.GithubReviewComment, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.html_url
|
||||
)
|
||||
}
|
||||
})
|
||||
this.app.webhooks.on('pull_request_review_thread', async ({ payload, name, id }) => {
|
||||
const repoWorker = this.getWorker(payload.installation?.id)
|
||||
if (repoWorker !== undefined) {
|
||||
catchEventError(
|
||||
repoWorker.handleEvent(github.class.GithubReviewThread, payload.installation?.id, payload),
|
||||
payload.action,
|
||||
name,
|
||||
id,
|
||||
payload.repository.html_url
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async revokeUserAuth (record: GithubUserRecord): Promise<void> {
|
||||
for (const [ws, acc] of Object.entries(record.accounts)) {
|
||||
await this.updateAccountAuthRecord({ workspace: ws, accountId: acc }, { login: record._id }, undefined, true)
|
||||
}
|
||||
}
|
||||
|
||||
getWorker (installationId?: number): GithubWorker | undefined {
|
||||
if (installationId === undefined) {
|
||||
return
|
||||
}
|
||||
for (const w of this.clients.values()) {
|
||||
for (const i of w.integrations.values()) {
|
||||
if (i.installationId === installationId) {
|
||||
return w
|
||||
}
|
||||
}
|
||||
for (const i of w.integrationsRaw.values()) {
|
||||
if (i.installationId === installationId) {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createNodeMiddleware } from '@octokit/webhooks'
|
||||
import { App } from 'octokit'
|
||||
|
||||
import config from './config'
|
||||
import { PlatformWorker } from './platform'
|
||||
|
||||
import bp from 'body-parser'
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { Account, BrandingMap, MeasureContext, Ref } from '@hcengineering/core'
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
import serverCore from '@hcengineering/server-core'
|
||||
import { decodeToken } from '@hcengineering/server-token'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Promise<void> {
|
||||
// Create an authenticated Octokit client authenticated as a GitHub App
|
||||
ctx.info('Running Huly Github integration', { appId: config.AppID, clientID: config.ClientID })
|
||||
|
||||
setMetadata(serverCore.metadata.FrontUrl, config.FrontURL)
|
||||
setMetadata(serverClient.metadata.Endpoint, config.AccountsURL)
|
||||
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
|
||||
|
||||
const octokitApp: App = new App({
|
||||
appId: config.AppID,
|
||||
privateKey: config.PrivateKey,
|
||||
webhooks: {
|
||||
secret: config.WebhookSecret
|
||||
}
|
||||
})
|
||||
|
||||
// Optional: Get & log the authenticated app's name
|
||||
const { data } = await octokitApp.octokit.request('/app')
|
||||
|
||||
// Read more about custom logging: https://github.com/octokit/core.js#logging
|
||||
octokitApp.octokit.log.debug(`Authenticated as '${data.name as string}'`)
|
||||
|
||||
// Optional: Handle errors
|
||||
octokitApp.webhooks.onError((error) => {
|
||||
Analytics.handleError(error)
|
||||
ctx.error('error', { error, event: error.event })
|
||||
})
|
||||
|
||||
// Launch a web server to listen for GitHub webhooks
|
||||
const port = config.Port
|
||||
const path = '/api/webhook'
|
||||
const localWebhookUrl = `http://localhost:${port}${path}`
|
||||
|
||||
// See https://github.com/octokit/webhooks.js/#createnodemiddleware for all options
|
||||
const middleware = createNodeMiddleware(octokitApp.webhooks as any, { path })
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(middleware as any)
|
||||
app.use(cors())
|
||||
app.use(bp.json())
|
||||
app.use(bp.urlencoded({ extended: true }))
|
||||
|
||||
// Initialize platform worker
|
||||
let worker: PlatformWorker
|
||||
try {
|
||||
worker = await PlatformWorker.create(ctx, octokitApp, brandingMap)
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
ctx.error('Failed to init Service', { err })
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
app.post('/api/v1/installation', async (req, res) => {
|
||||
try {
|
||||
const payloadData: {
|
||||
installationId: number
|
||||
accountId: Ref<Account>
|
||||
token: string
|
||||
} = req.body
|
||||
|
||||
const decodedToken = decodeToken(payloadData.token)
|
||||
ctx.info('/api/v1/installation', {
|
||||
email: decodedToken.email,
|
||||
workspaceName: decodedToken.workspace.name,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
await ctx.withLog('map-installation', {}, async (ctx) => {
|
||||
await worker.mapInstallation(
|
||||
ctx,
|
||||
decodedToken.workspace.name,
|
||||
payloadData.installationId,
|
||||
payloadData.accountId
|
||||
)
|
||||
})
|
||||
res.status(200)
|
||||
res.json({})
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
res.status(401)
|
||||
res.json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
app.post('/api/v1/auth', async (req, res) => {
|
||||
try {
|
||||
const payloadData: {
|
||||
code: string
|
||||
state: string
|
||||
accountId: Ref<Account>
|
||||
token: string
|
||||
} = req.body
|
||||
|
||||
const decodedData: {
|
||||
accountId: Ref<Account>
|
||||
token: string
|
||||
op: string
|
||||
} = JSON.parse(atob(payloadData.state))
|
||||
|
||||
const decodedToken = decodeToken(decodedData.token)
|
||||
|
||||
await ctx.withLog('request-github-access-token', {}, async (ctx) => {
|
||||
await worker.requestGithubAccessToken({
|
||||
workspace: decodedToken.workspace.name,
|
||||
accountId: payloadData.accountId,
|
||||
code: payloadData.code,
|
||||
state: payloadData.state
|
||||
})
|
||||
})
|
||||
res.status(200)
|
||||
res.json({})
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
res.status(401)
|
||||
res.json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
app.post('/api/v1/installation-remove', async (req, res) => {
|
||||
try {
|
||||
const payloadData: {
|
||||
installationId: number
|
||||
token: string
|
||||
} = req.body
|
||||
|
||||
const decodedToken = decodeToken(payloadData.token)
|
||||
ctx.info('/api/v1/installation-remove', {
|
||||
email: decodedToken.email,
|
||||
workspaceName: decodedToken.workspace.name,
|
||||
body: req.body
|
||||
})
|
||||
|
||||
await ctx.withLog('map-installation', {}, async (ctx) => {
|
||||
await worker.removeInstallation(ctx, decodedToken.workspace.name, payloadData.installationId)
|
||||
})
|
||||
res.status(200)
|
||||
res.json({})
|
||||
} catch (err: any) {
|
||||
Analytics.handleError(err)
|
||||
res.status(401)
|
||||
res.json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(port, () => {
|
||||
ctx.info(`Server is listening for events at: ${localWebhookUrl}`)
|
||||
ctx.info('Press Ctrl + C to quit.')
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Person, PersonAccount } from '@hcengineering/contact'
|
||||
import {
|
||||
Account,
|
||||
Branding,
|
||||
Class,
|
||||
Data,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
Ref,
|
||||
Space,
|
||||
Status,
|
||||
TxOperations,
|
||||
WithLookup,
|
||||
WorkspaceIdWithUrl,
|
||||
type Blob
|
||||
} from '@hcengineering/core'
|
||||
import { LiveQuery } from '@hcengineering/query'
|
||||
import { ProjectType, TaskType } from '@hcengineering/task'
|
||||
import { MarkupNode } from '@hcengineering/text'
|
||||
import { User } from '@octokit/webhooks-types'
|
||||
import {
|
||||
DocSyncInfo,
|
||||
GithubIntegration,
|
||||
GithubIntegrationRepository,
|
||||
GithubMilestone,
|
||||
GithubProject,
|
||||
GithubUserInfo
|
||||
} from '@hcengineering/github'
|
||||
import { Octokit } from 'octokit'
|
||||
import { GithubProjectV2 } from './sync/githubTypes'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const githubSyncVersion = 'v7'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const githubExternalSyncVersion = 'v4'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const githubDerivedSyncVersion = 'v1'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Workspace {
|
||||
_id: string
|
||||
workspace: string
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface IntegrationContainer {
|
||||
integration: WithLookup<GithubIntegration>
|
||||
installationId: number
|
||||
installationName: string
|
||||
octokit: Octokit
|
||||
|
||||
projectStructure: Map<Ref<GithubProject | GithubMilestone>, GithubProjectV2>
|
||||
|
||||
enabled: boolean
|
||||
synchronized: Set<string>
|
||||
|
||||
login: string
|
||||
loginNodeId: string
|
||||
type: GithubIntegration['type']
|
||||
|
||||
syncLock: Map<Ref<Doc>, Promise<void>>
|
||||
}
|
||||
|
||||
export type UserInfo = Data<GithubUserInfo>
|
||||
|
||||
export interface ContainerFocus {
|
||||
container: IntegrationContainer
|
||||
repository: GithubIntegrationRepository[]
|
||||
project: GithubProject
|
||||
}
|
||||
|
||||
export interface IntegrationManager {
|
||||
liveQuery: LiveQuery
|
||||
getContainer: (space: Ref<Space>) => Promise<ContainerFocus | undefined>
|
||||
getAccount: (user?: UserInfo | null) => Promise<PersonAccount | undefined>
|
||||
getAccountU: (user: User) => Promise<PersonAccount | undefined>
|
||||
getOctokit: (account: Ref<PersonAccount>) => Promise<Octokit | undefined>
|
||||
getMarkup: (
|
||||
container: IntegrationContainer,
|
||||
text?: string | null,
|
||||
preprocessor?: (nodes: MarkupNode) => Promise<void>
|
||||
) => Promise<string>
|
||||
getMarkdown: (text?: string | null, preprocessor?: (nodes: MarkupNode) => Promise<void>) => Promise<string>
|
||||
sync: () => void
|
||||
getGithubLogin: (container: IntegrationContainer, account: Ref<Person>) => Promise<UserInfo | undefined>
|
||||
|
||||
uploadFile: (patch: string, file?: string) => Promise<Blob | undefined>
|
||||
|
||||
getStatuses: (type: Ref<TaskType> | undefined) => Promise<Status[]>
|
||||
getProjectStatuses: (type: Ref<ProjectType> | undefined) => Promise<Status[]>
|
||||
getProjectType: (type: Ref<ProjectType>) => Promise<ProjectType | undefined>
|
||||
getTaskType: (type: Ref<TaskType>) => Promise<TaskType | undefined>
|
||||
|
||||
getTaskTypeOf: (project: Ref<ProjectType>, ofClass: Ref<Class<Doc>>) => Promise<TaskType | undefined>
|
||||
|
||||
handleEvent: <T>(
|
||||
requestClass: Ref<Class<Doc>>,
|
||||
integrationId: number | undefined,
|
||||
repo: GithubIntegrationRepository,
|
||||
event: T
|
||||
) => Promise<void>
|
||||
|
||||
doSyncFor: (docs: DocSyncInfo[]) => Promise<void>
|
||||
getWorkspaceId: () => WorkspaceIdWithUrl
|
||||
getBranding: () => Branding | null
|
||||
|
||||
getProjectAndRepository: (
|
||||
repositoryId: string
|
||||
) => Promise<{ project?: GithubProject, repository?: GithubIntegrationRepository }>
|
||||
|
||||
checkMarkdownConversion: (
|
||||
container: IntegrationContainer,
|
||||
body: string
|
||||
) => Promise<{ markdownCompatible: boolean, markdown: string }>
|
||||
|
||||
isPlatformUser: (account: Ref<PersonAccount>) => Promise<boolean>
|
||||
}
|
||||
|
||||
export type ExternalSyncField = 'externalVersion' | 'derivedVersion'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
* Will perform synchronization of document and external document.
|
||||
*/
|
||||
export interface DocSyncManager {
|
||||
// Initialize the mapper.
|
||||
init: (provider: IntegrationManager) => Promise<void>
|
||||
// Perform synchronization of document with external source.
|
||||
sync: (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
parent: DocSyncInfo | undefined,
|
||||
derivedClient: TxOperations
|
||||
) => Promise<DocumentUpdate<DocSyncInfo> | undefined>
|
||||
|
||||
handleDelete: (
|
||||
existing: Doc | undefined,
|
||||
info: DocSyncInfo,
|
||||
derivedClient: TxOperations,
|
||||
deleteExisting: boolean,
|
||||
parent?: DocSyncInfo
|
||||
) => Promise<boolean>
|
||||
|
||||
// Perform synchronization with external source.
|
||||
externalFullSync: (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
projects: GithubProject[],
|
||||
repositories: GithubIntegrationRepository[]
|
||||
) => Promise<void>
|
||||
|
||||
// Perform synchronization with external source.
|
||||
externalSync: (
|
||||
integration: IntegrationContainer,
|
||||
derivedClient: TxOperations,
|
||||
kind: ExternalSyncField,
|
||||
syncDocs: DocSyncInfo[],
|
||||
repository: GithubIntegrationRepository,
|
||||
project: GithubProject
|
||||
) => Promise<void>
|
||||
|
||||
handleEvent: <T>(integration: IntegrationContainer, derivedClient: TxOperations, event: T) => Promise<void>
|
||||
|
||||
externalDerivedSync: boolean
|
||||
|
||||
repositoryDisabled: (integration: IntegrationContainer, repo: GithubIntegrationRepository) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubIntegrationRecord {
|
||||
installationId: number
|
||||
workspace: string
|
||||
accountId: Ref<Account>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface GithubUserRecord {
|
||||
_id: string // login
|
||||
code?: string | null
|
||||
token?: string
|
||||
expiresIn?: number | null // seconds
|
||||
refreshToken?: string | null
|
||||
refreshTokenExpiresIn?: number | null
|
||||
authorized?: boolean
|
||||
state?: string
|
||||
scope?: string
|
||||
error?: string | null
|
||||
|
||||
accounts: Record<string, Ref<Account>>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Doc } from '@hcengineering/core'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
export function equalExceptKeys<T extends Doc> (a: T | T[], b: T | T[], keys: (keyof T)[]): boolean {
|
||||
function equal (a: T, b: T): boolean {
|
||||
const _a = { ...a }
|
||||
const _b = { ...b }
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
if (key in _a) delete _a[key]
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
if (key in _b) delete _b[key]
|
||||
}
|
||||
return deepEqual(_a, _b)
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!equal(a[i], b[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!Array.isArray(a) && !Array.isArray(b)) {
|
||||
return equal(a, b)
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user