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
@@ -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
}