// // Copyright © 2023 Hardcore Engineering Inc. // /* TODO: * Add since to synchronization */ import activity from '@hcengineering/activity' import { Analytics } from '@hcengineering/analytics' import { CollaboratorClient } from '@hcengineering/collaborator-client' import { PersonAccount } from '@hcengineering/contact' import core, { Account, AttachedDoc, Class, CollaborativeDoc, Doc, DocumentUpdate, Markup, MeasureContext, Ref, Space, Status, TxOperations, generateId } from '@hcengineering/core' import { IntlString } from '@hcengineering/platform' import { LiveQuery } from '@hcengineering/query' import task, { TaskType } from '@hcengineering/task' import { MarkupNode, MarkupNodeType, areEqualMarkups, markupToJSON, traverseNode } from '@hcengineering/text' import tracker, { Issue, IssuePriority } from '@hcengineering/tracker' import time, { type ToDo } from '@hcengineering/time' import { ProjectsV2ItemEvent } from '@octokit/webhooks-types' import github, { DocSyncInfo, GithubFieldMapping, GithubIntegrationRepository, GithubIssue, GithubIssue as GithubIssueP, GithubMilestone, GithubProject } from '@hcengineering/github' import { deepEqual } from 'fast-equals' import { Octokit } from 'octokit' import { ContainerFocus, IntegrationContainer, IntegrationManager, githubExternalSyncVersion, githubSyncVersion } from '../types' import { GithubDataType, GithubProjectV2FieldOption, GithubProjectV2Item, GithubProjectV2ItemFieldValue, IssueExternalData, fieldValues, projectValue, supportedGithubTypes } from './githubTypes' import { appendGuestLink, stripGuestLink } from './guest' import { syncConfig } from './syncConfig' import { collectUpdate, compareMarkdown, deleteObjects, errorToObj, getCreateStatus, getType, isGHWriteAllowed } from './utils' /** * @public */ export type WithMarkup = { [P in keyof T]: T[P] extends CollaborativeDoc ? Markup : T[P] } /** * @public */ export type GithubIssueData = Omit< WithMarkup, | 'commits' | 'attachments' | 'commits' | 'number' | 'files' | 'space' | 'identifier' | 'rank' | 'status' | 'priority' | 'subIssues' | 'parents' | 'estimation' | 'reportedTime' | 'reports' | 'childInfo' | 'dueDate' | 'kind' | 'reviews' | 'reviewThreads' | 'reviewComments' | 'component' | keyof AttachedDoc > & Record /** * @public */ export type IssueUpdate = DocumentUpdate> /** * @public */ export interface IssueSyncTarget { project: GithubProject mappings: GithubFieldMapping[] target: GithubProject | GithubMilestone prjData?: GithubProjectV2Item } export abstract class IssueSyncManagerBase { provider!: IntegrationManager constructor ( readonly ctx: MeasureContext, readonly client: TxOperations, readonly lq: LiveQuery, readonly collaborator: CollaboratorClient ) {} async init (provider: IntegrationManager): Promise { this.provider = provider } async getAssignees (issue: IssueExternalData): Promise { // Find Assignees and reviewers const assignees: PersonAccount[] = [] for (const o of issue.assignees.nodes) { const acc = await this.provider.getAccount(o) if (acc !== undefined) { assignees.push(acc) } } return assignees } async processProjectV2Event ( integration: IntegrationContainer, event: ProjectsV2ItemEvent, derivedClient: TxOperations, prj: GithubProject ): Promise { const account = (await this.provider.getAccountU(event.sender))?._id ?? core.account.System switch (event.action) { case 'edited': { const itemId = event.projects_v2_item.node_id const projectId = event.projects_v2_item.project_node_id try { const actualContent: { node: { id: string content: { id: string url: string number: number } fieldValues: { nodes: GithubProjectV2ItemFieldValue[] } } } = (await integration.octokit?.graphql( `query listIssue($nodeId: ID!) { node(id: $nodeId) { ... on ProjectV2Item { id content { ... on Issue { id number url } ... on PullRequest { id number url } } ${fieldValues} } } }`, { nodeId: itemId } )) as any const syncData = await this.client.findOne(github.class.DocSyncInfo, { url: (actualContent.node.content.url ?? '').toLowerCase() }) if (syncData !== undefined) { const milestone = ( await this.provider.liveQuery.queryFind(github.mixin.GithubMilestone, {}) ).find((it) => it.projectNodeId === projectId) const target: IssueSyncTarget | undefined = milestone !== undefined ? { mappings: milestone.mappings, project: prj, target: milestone } : prj.projectNodeId === projectId ? this.getProjectIssueTarget(prj) : undefined if (target === undefined) { // Not our project, we should just update external return } this.ctx.info('event for issue', { url: syncData.url, workspace: this.provider.getWorkspaceId().name }) const externalData = syncData.external as IssueExternalData // We need to replace field values we retrieved target.prjData = externalData.projectItems.nodes.find( (it) => it.project.id === event.projects_v2_item.project_node_id ) if (target.prjData === undefined) { target.prjData = { fieldValues: actualContent.node.fieldValues, id: event.projects_v2_item.node_id, type: 'ISSUE', project: { id: prj.projectNodeId as string, number: prj.projectNumber as number } } externalData.projectItems.nodes.push(target.prjData) } else { target.prjData.fieldValues = actualContent.node.fieldValues } // Store github values await derivedClient.update(syncData, { external: externalData, externalVersion: githubExternalSyncVersion }) if (event.changes.field_value === undefined) { this.ctx.info('No changes for change event', { event, workspace: this.provider.getWorkspaceId().name }) return } let needProjectRefresh = false const update: DocumentUpdate & Record = {} let structure = integration.projectStructure.get(target.target._id) const repositories = await this.provider.liveQuery.queryFind( github.class.GithubIntegrationRepository, {} ) for (const f of target.prjData.fieldValues?.nodes ?? []) { if (!('id' in f)) { continue } // Check if we need to update project structure if (structure !== undefined) { const ff = structure.projectV2.fields.edges.find((it) => it.node.id === f.field.id) if (ff === undefined && supportedGithubTypes.has(f.field.dataType)) { // We have missing field. needProjectRefresh = true } } if (needProjectRefresh) { const repo = repositories.find((it) => it._id === syncData.repository) if (repo !== undefined) { await this.provider.handleEvent(github.class.GithubIntegration, integration.installationId, repo, {}) structure = integration.projectStructure.get(prj._id) } } const taskTypes = ( await this.provider.liveQuery.queryFind(task.class.TaskType, { parent: prj.type }) ).filter((it) => this.client.getHierarchy().isDerived(it.targetClass, syncData.objectClass)) // TODO: Use GithubProject configuration to specify target type for issues if (taskTypes.length === 0) { // Missing required task type this.ctx.error('Missing required task type, in Event.') } if (event.changes.field_value.field_node_id === f.field.id && taskTypes.length > 0) { const ff = await this.toPlatformField( { container: integration, project: prj, repository: repositories.filter((it) => it.githubProject === prj._id) }, f, target, taskTypes[0] ) if (ff === undefined) { continue } const { value, mapping } = ff if (value !== undefined) { update[mapping.name] = value } continue } } if (Object.keys(update).length > 0) { await this.handleUpdate(externalData, derivedClient, update, account, prj, false, syncData) } } } catch (err: any) { Analytics.handleError(err) this.ctx.error(err, event) } break } } } async handleUpdate ( external: IssueExternalData, derivedClient: TxOperations, update: IssueUpdate, account: Ref, prj: GithubProject, needSync: boolean, syncData?: DocSyncInfo, verifyUpdate?: ( state: DocSyncInfo, existing: Issue, external: IssueExternalData, update: IssueUpdate ) => Promise, extraSyncUpdate?: DocumentUpdate ): Promise { if (Object.keys(update).length === 0 && !needSync) { return } syncData = syncData ?? (await this.client.findOne(github.class.DocSyncInfo, { url: (external.url ?? '').toLowerCase() })) if (syncData !== undefined) { const doc: Issue | undefined = await this.client.findOne(syncData.objectClass, { _id: syncData._id as unknown as Ref }) // Use now as modified date for events. const lastModified = new Date().getTime() if (doc !== undefined && ((await verifyUpdate?.(syncData, doc, external, update)) ?? true)) { const issueData: DocumentUpdate = { ...update, description: doc.description } if ( update.description !== undefined && !areEqualMarkups(update.description, syncData.current?.description ?? '') ) { try { const versionId = `${Date.now()}` issueData.description = await this.collaborator.updateContent( doc.description, { description: update.description }, { versionId, versionName: versionId, createdBy: account } ) } catch (err: any) { Analytics.handleError(err) this.ctx.error(err) } } else { delete update.description } // Sync all possibly updated action items states if (update.description !== undefined) { const node = markupToJSON(update.description) const todos: Record = {} traverseNode(node, (n) => { if (n.type === MarkupNodeType.todoItem && n.attrs?.todoid !== undefined) { todos[n.attrs.todoid as string] = (n.attrs.checked as boolean) ?? false } return true }) const updateTodos = this.client.apply('todos') for (const [k, v] of Object.entries(todos)) { await updateTodos.updateDoc(time.class.ToDo, time.space.ToDos, k as Ref, { doneOn: v ? Date.now() : null }) } await updateTodos.commit() } await derivedClient.diffUpdate( syncData, { external, externalVersion: githubExternalSyncVersion, current: { ...syncData.current, ...update }, needSync: needSync ? '' : githubSyncVersion, // No need to sync after operation. derivedVersion: '', // Check derived changes lastModified, lastGithubUser: account, ...extraSyncUpdate }, lastModified ) await this.client.diffUpdate( this.client.getHierarchy().as(doc, prj.mixinClass), issueData, lastModified, account ) this.provider.sync() } } if (needSync) { this.provider.sync() } } async addIssueToProject ( container: ContainerFocus, okit: Octokit, issue: IssueExternalData, projectTarget: string ): Promise { const query = `mutation addIssueToProject($project: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $project, contentId: $contentId}) { item { ${projectValue} id type ${fieldValues} } } }` if (isGHWriteAllowed()) { const response: any = await okit.graphql(query, { project: projectTarget, contentId: issue.id }) return response.addProjectV2ItemById.item } } async removeIssueFromProject (okit: Octokit, projectTarget: string, issueId: string): Promise { try { const query = `mutation removeIssueToProject($project: ID!, $contentId: ID!) { deleteProjectV2Item(input: {projectId: $project, itemId: $contentId}) { deletedItemId } }` if (isGHWriteAllowed()) { await okit.graphql(query, { project: projectTarget, contentId: issueId }) } } catch (err: any) { Analytics.handleError(err) this.ctx.error(err) } } findOption ( container: ContainerFocus, field: GithubProjectV2ItemFieldValue, target: GithubProject | GithubMilestone ): GithubProjectV2FieldOption | undefined { const structure = container.container.projectStructure.get(target._id) if (structure === undefined) { return { id: field.field.id, name: field.field.name, color: field.color ?? '', description: field.description ?? '' } } const pField = structure.projectV2.fields.edges.find((it) => it.node.id === field.field.id) if (pField === undefined) { return { id: field.field.id, name: field.field.name, color: field.color ?? '', description: field.description ?? '' } } return (pField.node.options ?? []).find((it) => it.id === field.optionId) } findOptionId (container: ContainerFocus, fieldId: string, value: string, target: IssueSyncTarget): string | undefined { const structure = container.container.projectStructure.get(target.target._id) if (structure === undefined) { return } const pField = structure.projectV2.fields.edges.find((it) => it.node.id === fieldId) if (pField === undefined) { return undefined } return (pField.node.options ?? []).find((it) => it.name.toLowerCase() === value.toLowerCase())?.id } async toPlatformField ( container: ContainerFocus, // eslint-disable-next-line @typescript-eslint/ban-types field: GithubProjectV2ItemFieldValue | {}, target: IssueSyncTarget, taskType: TaskType ): Promise<{ value: any, mapping: GithubFieldMapping } | undefined> { if (!('field' in field)) { return } const mapping = target.mappings.find((it) => it.githubId === field.field.id) if (mapping === undefined) { return undefined } if (mapping.name === 'status') { const option = this.findOption(container, field, target.target) if (option === undefined) { return } return { value: await getCreateStatus( this.ctx, this.provider, this.client, container.project, option?.name, option.description, option.color, taskType ), mapping } } if (mapping.name === 'priority') { const values: Record = { '': IssuePriority.NoPriority, High: IssuePriority.High, Medium: IssuePriority.Medium, Low: IssuePriority.Low, Urgent: IssuePriority.Urgent } const option = this.findOption(container, field, target.target) return { value: values[option?.name ?? ''] ?? IssuePriority.NoPriority, mapping } } switch (field.field.dataType) { case 'DATE': return { value: field.date !== undefined ? new Date(field.date).getTime() : null, mapping } case 'NUMBER': return { value: field.number, mapping } case 'TEXT': return { value: field.text, mapping } case 'SINGLE_SELECT': { const option = this.findOption(container, field, target.target) return { value: option?.name, mapping } } } } async fillProjectV2Fields ( target: IssueSyncTarget, container: ContainerFocus, issueData: Record, taskType: TaskType ): Promise { for (const f of target.prjData?.fieldValues?.nodes ?? []) { const ff = await this.toPlatformField(container, f, target, taskType) if (ff === undefined) { continue } const { value, mapping } = ff if (value !== undefined) { ;(issueData as any)[mapping.name] = value } } } async updateIssueValues ( target: IssueSyncTarget, okit: Octokit, values: { id: string, value: any, dataType: GithubDataType }[] ): Promise<{ error: any, response: any }[]> { function getValue (val: { id: string, value: any, dataType: GithubDataType }): string { switch (val.dataType) { case 'SINGLE_SELECT': return `singleSelectOptionId: "${val.value as string}"` case 'DATE': return `date: "${new Date(val.value).toISOString()}"` case 'NUMBER': return `number: ${val.value as number}` case 'TEXT': return `text: "${val.value as string}"` } } const errors: any[] = [] const itm = ` { projectV2Item { id type ${projectValue} ${fieldValues} } }\n` let response: any = {} if (isGHWriteAllowed()) { for (const val of values) { const q = ` mutation updateField($project: ID!, $itemId: ID!) { updateProjectV2ItemFieldValue( input: {projectId: $project, itemId: $itemId, fieldId: "${val.id}", value: {${getValue(val)}}} ) ${itm} }` try { response = await okit.graphql(q, { project: target.target.projectNodeId, itemId: target.prjData?.id as string }) } catch (err: any) { Analytics.handleError(err) // Failed to update one particular value, skip it. this.ctx.error('error during field update', { error: err, response }) errors.push({ error: err, response }) } } } return errors } abstract fillBackChanges (update: DocumentUpdate, existing: GithubIssue, external: any): Promise async addConnectToMessage ( msg: IntlString, prj: Ref, issueId: Ref, _class: Ref>, issueExternal: { url: string, number: number }, repository: GithubIntegrationRepository ): Promise { await this.client.addCollection(activity.class.ActivityInfoMessage, prj, issueId, _class, 'activity', { message: msg, icon: github.icon.Github, props: { url: issueExternal.url, repository: repository.url?.replace('api.github.com/repos', 'github.com'), repoName: repository.name, number: issueExternal.number } }) } stripGuestLink = async (data: MarkupNode): Promise => { await stripGuestLink(data) } abstract performIssueFieldsUpdate ( info: DocSyncInfo, existing: WithMarkup, platformUpdate: DocumentUpdate, issueData: GithubIssueData, container: ContainerFocus, issueExternal: IssueExternalData, okit: Octokit, account: Ref ): Promise abstract afterSync (existing: Issue, account: Ref, issueExternal: any, info: DocSyncInfo): Promise async handleDiffUpdate ( target: IssueSyncTarget, existing: WithMarkup, info: DocSyncInfo, issueData: GithubIssueData, container: ContainerFocus, issueExternal: IssueExternalData, account: Ref, accountGH: Ref, syncToProject: boolean ): Promise> { let needUpdate = false if (!this.client.getHierarchy().hasMixin(existing, github.mixin.GithubIssue)) { await this.ctx.withLog( 'create mixin issue: GithubIssue', {}, async () => { await this.client.createMixin( existing._id as Ref, existing._class, existing.space, github.mixin.GithubIssue, { githubNumber: issueExternal.number, url: issueExternal.url, repository: info.repository as Ref } ) await this.notifyConnected(container, info, existing, issueExternal) }, { identifier: existing.identifier, url: issueExternal.url } ) // Re iterate to have existing value with mixin inside. needUpdate = true } else { const ghIssue = this.client.getHierarchy().as(existing, github.mixin.GithubIssue) await this.client.diffUpdate(ghIssue, { githubNumber: issueExternal.number, url: issueExternal.url, repository: info.repository as Ref }) if (ghIssue.url !== issueExternal.url) { await this.notifyConnected(container, info, existing, issueExternal) } } if (!this.client.getHierarchy().hasMixin(existing, container.project.mixinClass)) { await this.ctx.withLog( 'create mixin issue', {}, async () => await this.client.createMixin( existing._id as Ref, existing._class, existing.space, container.project.mixinClass, {} ), { identifier: existing.identifier, url: issueExternal.url } ) // Re iterate to have existing value with mixin inside. needUpdate = true } if (needUpdate) { return { needSync: '' } } const existingIssue = this.client.getHierarchy().as(existing, container.project.mixinClass) const previousData: GithubIssueData = info.current ?? ({} as unknown as GithubIssueData) const type = await this.provider.getTaskTypeOf(container.project.type, existing._class) const stst = await this.provider.getStatuses(type?._id) const update = collectUpdate(previousData, issueData, Object.keys(issueData)) const allAttributes = this.client.getHierarchy().getAllAttributes(container.project.mixinClass) const platformUpdate = collectUpdate(previousData, existingIssue, Array.from(allAttributes.keys())) const okit = (await this.provider.getOctokit(account as Ref)) ?? container.container.octokit // Remove current same values from update for (const [k, v] of Object.entries(update)) { if ((existingIssue as any)[k] === v) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (update as any)[k] } } if (update.description !== undefined) { if (areEqualMarkups(update.description, existingIssue.description)) { delete update.description } } for (const [k, v] of Object.entries(update)) { let pv = (platformUpdate as any)[k] if (k === 'description' && pv != null) { const mdown = await this.provider.getMarkdown(pv) pv = await this.provider.getMarkup(container.container, mdown, this.stripGuestLink) } if (pv != null && pv !== v) { // We have conflict of values, assume platform is more proper one. this.ctx.error('conflict', { id: existing.identifier, k }) // Assume platform change is more important in case of conflict values. // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (update as any)[k] continue } } await this.fillBackChanges(update, existingIssue, issueExternal) let needExternalSync = false if (container !== undefined && okit !== undefined) { // Check and update issue fields. needExternalSync = await this.performIssueFieldsUpdate( info, existing, platformUpdate, issueData, container, issueExternal, okit, account ) const fieldsUpdate: { id: string, value: any, dataType: GithubDataType }[] = [] // Collect field update. for (const [k, v] of Object.entries(platformUpdate)) { const mapping = target.mappings.find((it) => it.name === k) if (mapping === undefined) { continue } const attr = this.client.getHierarchy().getAttribute(mapping._class, mapping.name) if (attr.name === 'status') { // Handle status field const status = stst.find((it) => it._id === v) as Status const optionId = this.findOptionId(container, mapping.githubId, status.name, target) if (optionId !== undefined) { fieldsUpdate.push({ id: mapping.githubId, dataType: 'SINGLE_SELECT', value: optionId }) this.ctx.info(' => prepare issue status update', { url: issueExternal.url, name: status.name, workspace: this.provider.getWorkspaceId().name }) continue } } if (attr.name === 'priority') { const values: Record = { [IssuePriority.NoPriority]: '', [IssuePriority.High]: 'High', [IssuePriority.Medium]: 'Medium', [IssuePriority.Low]: 'Low', [IssuePriority.Urgent]: 'Urgent' } // Handle priority field TODO: Add clear of field const priorityName = values[v as IssuePriority] const optionId = this.findOptionId(container, mapping.githubId, priorityName, target) if (optionId !== undefined) { fieldsUpdate.push({ id: mapping.githubId, dataType: 'SINGLE_SELECT', value: optionId }) this.ctx.info(' => prepare issue priority update', { url: issueExternal.url, priority: priorityName, workspace: this.provider.getWorkspaceId().name }) continue } } const dataType = getType(attr) if (dataType === 'SINGLE_SELECT') { // Handle status field const optionId = this.findOptionId(container, mapping.githubId, v, target) if (optionId !== undefined) { fieldsUpdate.push({ id: mapping.githubId, dataType: 'SINGLE_SELECT', value: optionId }) this.ctx.info(` => prepare issue field ${attr.label} update`, { url: issueExternal.url, value: v, workspace: this.provider.getWorkspaceId().name }) continue } } if (dataType === undefined) { continue } fieldsUpdate.push({ id: mapping.githubId, dataType, value: v }) this.ctx.info(`=> prepare issue field ${attr.label} update`, { url: issueExternal.url, value: v, workspace: this.provider.getWorkspaceId().name }) } if (fieldsUpdate.length > 0 && syncToProject && target.prjData !== undefined) { const errors = await this.updateIssueValues(target, okit, fieldsUpdate) if (errors.length > 0) { return { externalVersion: '', needUpdate: githubSyncVersion, error: errors } } needExternalSync = true } // TODO: Add support for labels, milestone, assignees } // We need remove all readonly field values for (const k of Object.keys(update)) { // Skip readonly fields const attr = this.client.getHierarchy().findAttribute(target.project.mixinClass, k) if (attr?.readonly === true) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete (update as any)[k] continue } } // Update collaborative description if (update.description !== undefined) { this.ctx.info(`<= perform ${issueExternal.url} update to collaborator`, { workspace: this.provider.getWorkspaceId().name }) try { const versionId = `${Date.now()}` issueData.description = update.description update.description = await this.collaborator.updateContent( existingIssue.description, { description: update.description }, { versionId, versionName: versionId, createdBy: account } ) } catch (err: any) { Analytics.handleError(err) this.ctx.error('error during description update', err) } } if (Object.keys(update).length > 0) { // We have some fields to update of existing from external this.ctx.info(`<= perform ${issueExternal.url} update to platform`, { ...update, workspace: this.provider.getWorkspaceId().name }) await this.client.update(existingIssue, update, false, new Date().getTime(), accountGH) } await this.afterSync(existingIssue, accountGH, issueExternal, info) // We need to trigger external version retrieval, via sync or event, to prevent move sync operations from platform before we will be sure all is updated on github. return { current: issueData, needSync: githubSyncVersion, ...(needExternalSync ? { externalVersion: '' } : {}), lastGithubUser: null } } private async notifyConnected ( container: ContainerFocus, info: DocSyncInfo, existing: WithMarkup, issueExternal: IssueExternalData ): Promise { const repo = container.repository.find((it) => it._id === info.repository) as GithubIntegrationRepository await this.addConnectToMessage( existing._class === github.class.GithubPullRequest ? github.string.PullRequestConnectedActivityInfo : github.string.IssueConnectedActivityInfo, existing.space, existing._id, existing._class, issueExternal, repo ) } async collectIssueUpdate ( info: DocSyncInfo, doc: WithMarkup, platformUpdate: DocumentUpdate, issueData: Pick, 'title' | 'description' | 'assignee' | 'status'>, container: ContainerFocus, issueExternal: IssueExternalData, _class: Ref> ): Promise> { const issueUpdate: { title?: string body?: string stateReason?: string assigneeIds?: string[] } & Record = {} if (platformUpdate.title != null) { if (platformUpdate.title !== issueExternal.title) { issueUpdate.title = platformUpdate.title } issueData.title = platformUpdate.title } if (platformUpdate.description != null) { // Need to convert to markdown const pp = async (nodes: MarkupNode): Promise => { await appendGuestLink(this.client, doc, nodes, this.provider.getWorkspaceId(), this.provider.getBranding()) } issueUpdate.body = await this.provider.getMarkdown( platformUpdate.description ?? '', info.allowOpenInHuly === true ? pp : undefined ) issueData.description = await this.provider.getMarkup( container.container, issueUpdate.body ?? '', this.stripGuestLink ) // Of value is same, not need to update. if (compareMarkdown(issueUpdate.body, issueExternal.body)) { delete issueUpdate.body } } if (platformUpdate.assignee !== undefined) { const info = platformUpdate.assignee !== null ? await this.provider.getGithubLogin(container.container, platformUpdate.assignee) : undefined // Check external const currentAssignees = issueExternal.assignees.nodes.map((it) => it.id) currentAssignees.sort((a, b) => a.localeCompare(b)) issueUpdate.assigneeIds = info !== undefined ? [info.id] : [] issueUpdate.assigneeIds.sort((a, b) => a.localeCompare(b)) if (deepEqual(currentAssignees, issueUpdate.assigneeIds)) { // Same ids delete issueUpdate.assigneeIds } issueData.assignee = platformUpdate.assignee } const status = platformUpdate.status ?? issueData.status const type = await this.provider.getTaskTypeOf(container.project.type, _class) const statuses = await this.provider.getStatuses(type?._id) const st = statuses.find((it) => it._id === status) if (st !== undefined) { // Need to convert to two operations. switch (st.category) { case task.statusCategory.UnStarted: case task.statusCategory.ToDo: case task.statusCategory.Active: if (issueExternal.state !== 'OPEN') { issueUpdate.state = 'OPEN' } break case task.statusCategory.Won: if (issueExternal.state !== 'CLOSED' || issueExternal.stateReason !== 'COMPLETED') { issueUpdate.state = 'CLOSED' issueUpdate.stateReason = 'COMPLETED' } break case task.statusCategory.Lost: if (issueExternal.state !== 'CLOSED' || issueExternal.stateReason !== 'NOT_PLANNED') { issueUpdate.state = 'CLOSED' issueUpdate.stateReason = 'not_planed' // Not supported change to github } break } } return issueUpdate } async syncIssues ( _class: Ref>, repo: GithubIntegrationRepository, issues: IssueExternalData[], derivedClient: TxOperations ): Promise { if (repo.githubProject == null) { return } const syncInfo = await this.client.findAll(github.class.DocSyncInfo, { space: repo.githubProject, repository: repo._id, objectClass: _class, url: { $in: issues.map((it) => (it.url ?? '').toLowerCase()) } }) const ops = derivedClient.apply('sync-issyes' + generateId()) for (const issue of issues) { try { if (issue.url === undefined && Object.keys(issue).length === 0) { this.ctx.info('Retrieve empty document', { repo: repo.name, workspace: this.provider.getWorkspaceId().name }) continue } const existing = syncInfo.find((it) => it.url === issue.url.toLowerCase()) if (existing === undefined) { this.ctx.info('Create sync doc', { url: issue.url, workspace: this.provider.getWorkspaceId().name }) await ops.createDoc(github.class.DocSyncInfo, repo.githubProject, { url: issue.url.toLowerCase(), needSync: '', repository: repo._id, githubNumber: issue.number, objectClass: _class, external: issue, externalVersion: githubExternalSyncVersion, derivedVersion: '', externalVersionSince: '', lastModified: new Date(issue.updatedAt).getTime() }) } else { const externalEqual = deepEqual(existing.external, issue) if (!externalEqual || existing.externalVersion !== githubExternalSyncVersion) { this.ctx.info('Update sync doc', { url: issue.url, workspace: this.provider.getWorkspaceId().name }) await ops.diffUpdate( existing, { needSync: externalEqual ? existing.needSync : '', external: issue, externalVersion: githubExternalSyncVersion, derivedVersion: '', // Clear derived state to recalculate it. externalVersionSince: '', lastModified: new Date(issue.updatedAt).getTime() }, Date.now() ) } } } catch (err: any) { Analytics.handleError(err) this.ctx.error(err) } } await ops.commit(true) this.provider.sync() } async getMilestoneIssueTarget ( project: GithubProject, container: IntegrationContainer, existingIssue: Issue | undefined, external: IssueExternalData ): Promise { if (existingIssue !== undefined) { // Select a milestone project if (existingIssue.milestone != null) { const milestone = ( await this.provider.liveQuery.queryFind(github.mixin.GithubMilestone, {}) ).find((it) => it._id === existingIssue.milestone) if (milestone === undefined) { // Let's search for milestone, and if it doesn't have mixin, return undefined. const mstone = await this.client.findOne(github.mixin.GithubMilestone, { _id: existingIssue.milestone as Ref }) if (mstone === undefined) { return undefined } return null } return { project, mappings: milestone.mappings, target: milestone, prjData: external.projectItems.nodes.find((it) => it.project.id === milestone.projectNodeId) } } } } getProjectIssueTarget (project: GithubProject, external?: IssueExternalData): IssueSyncTarget { return { project, mappings: project.mappings, target: project, prjData: external?.projectItems.nodes.find((it) => it.project.id === project.projectNodeId) } } abstract deleteGithubDocument (container: ContainerFocus, account: Ref, id: string): Promise async handleDelete ( existing: Doc | undefined, info: DocSyncInfo, derivedClient: TxOperations, deleteExisting: boolean ): Promise { const container = await this.provider.getContainer(info.space) if (container === undefined) { return false } if ( container?.container === undefined || ((container.project.projectNodeId === undefined || !container.container.projectStructure.has(container.project._id)) && syncConfig.MainProject) ) { return false } const issueExternal = info.external as IssueExternalData | undefined if (issueExternal === undefined) { // No external issue yet, safe delete, since platform document will be deleted a well. return true } const account = existing?.createdBy ?? (await this.provider.getAccount(issueExternal.author))?._id ?? core.account.System const okit = (await this.provider.getOctokit(account as Ref)) ?? container.container.octokit if (existing !== undefined && issueExternal !== undefined) { let target = await this.getMilestoneIssueTarget( container.project, container.container, existing as Issue, issueExternal ) if (target === null) { // We need to wait, no milestone data yet. return false } if (target === undefined) { target = this.getProjectIssueTarget(container.project, issueExternal) } const isProjectProjectTarget = target.target.projectNodeId === target.project.projectNodeId const supportProjects = (isProjectProjectTarget && syncConfig.MainProject) || (!isProjectProjectTarget && syncConfig.SupportMilestones) // A target node id const targetNodeId: string | undefined = info.targetNodeId as string if (targetNodeId !== undefined && supportProjects) { const itemNode = issueExternal.projectItems.nodes.find((it) => it.project.id === targetNodeId) if (itemNode !== undefined) { await this.removeIssueFromProject(okit, targetNodeId, itemNode.id) } // Clear external project items info.external.projectItems = [] } } if (issueExternal !== undefined) { try { await this.deleteGithubDocument(container, account, issueExternal.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) this.ctx.error('Error', { err }) await derivedClient.update(info, { error: errorToObj(err) }) } } } if (existing !== undefined && deleteExisting) { const childItems = await derivedClient.findAll(github.class.DocSyncInfo, { parentUrl: (issueExternal.url ?? '').toLowerCase() }) for (const u of childItems) { // We need just to clean all of them, since child's for issue are comments for now. await derivedClient.remove(u) } await deleteObjects(this.ctx, this.client, [existing], account) } return true } protected async createErrorSyncDataByUrl ( url: string, githubNumber: number, date: Date, derivedClient: TxOperations, repo: GithubIntegrationRepository, err: any, _class: Ref> = tracker.class.Issue ): Promise { const syncData = await this.client.findOne(github.class.DocSyncInfo, { url: url.toLowerCase() }) if (syncData === undefined) { await derivedClient?.createDoc(github.class.DocSyncInfo, repo.githubProject as Ref, { url, needSync: githubSyncVersion, // We need external sync first. githubNumber, externalVersion: '', repository: repo._id, objectClass: _class, external: {}, error: errorToObj(err), lastModified: date.getTime() }) // We need trigger comments, if their sync data created before const childInfos = await this.client.findAll(github.class.DocSyncInfo, { parent: url.toLowerCase() }) for (const child of childInfos) { await derivedClient?.update(child, { needSync: '' }) } this.provider.sync() } } }