Move services to public (#6156)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2024-07-28 14:55:43 +07:00
committed by Andrey Sobolev
parent 9aad4a4561
commit ddecae80dd
578 changed files with 109084 additions and 390 deletions
+24
View File
@@ -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
}
+50
View File
@@ -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)
}
+124
View File
@@ -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
+59
View File
@@ -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)
+96
View File
@@ -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)
}
@@ -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\n![](http://localhost:8080/files?workspace=github&file=76e25453-186d-46e5-b9cd-d296a5342ce2&width=997)qwe 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\n![](http://localhost76e25453-186d-46e5-b9cd-d296a5342ce2&width=997)qwe 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&reg;: <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 ![This is Alt](http://url/a.png "This is title") 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>
})
}
}
+973
View File
@@ -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
}
}
}
}
}
+173
View File
@@ -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&reg;:'
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
}
+208
View File
@@ -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>>
}
+31
View File
@@ -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