platform/services/github/pod-github/src/sync/issueBase.ts
Andrey Sobolev d7820206c0
UBERF-7944: Support for not_planed close for issues (#6396)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
2024-08-27 14:10:50 +07:00

1312 lines
43 KiB
TypeScript

//
// 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<T> = {
[P in keyof T]: T[P] extends CollaborativeDoc ? Markup : T[P]
}
/**
* @public
*/
export type GithubIssueData = Omit<
WithMarkup<Issue>,
| '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<string, any>
/**
* @public
*/
export type IssueUpdate = DocumentUpdate<WithMarkup<Issue>>
/**
* @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<void> {
this.provider = provider
}
async getAssignees (issue: IssueExternalData): Promise<PersonAccount[]> {
// 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<void> {
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<GithubMilestone>(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<Issue> & Record<string, any> = {}
let structure = integration.projectStructure.get(target.target._id)
const repositories = await this.provider.liveQuery.queryFind<GithubIntegrationRepository>(
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<Account>,
prj: GithubProject,
needSync: boolean,
syncData?: DocSyncInfo,
verifyUpdate?: (
state: DocSyncInfo,
existing: Issue,
external: IssueExternalData,
update: IssueUpdate
) => Promise<boolean>,
extraSyncUpdate?: DocumentUpdate<DocSyncInfo>
): Promise<void> {
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<Issue>(syncData.objectClass, {
_id: syncData._id as unknown as Ref<Issue>
})
// 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<Issue> = { ...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<string, boolean> = {}
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<ToDo>, {
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<GithubProjectV2Item | undefined> {
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<void> {
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<string, IssuePriority> = {
'': 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<string, any>,
taskType: TaskType
): Promise<void> {
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<Issue>, existing: GithubIssue, external: any): Promise<void>
async addConnectToMessage (
msg: IntlString,
prj: Ref<Space>,
issueId: Ref<Issue>,
_class: Ref<Class<Doc>>,
issueExternal: { url: string, number: number },
repository: GithubIntegrationRepository
): Promise<void> {
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<void> => {
await stripGuestLink(data)
}
abstract performIssueFieldsUpdate (
info: DocSyncInfo,
existing: WithMarkup<Issue>,
platformUpdate: DocumentUpdate<Issue>,
issueData: GithubIssueData,
container: ContainerFocus,
issueExternal: IssueExternalData,
okit: Octokit,
account: Ref<Account>
): Promise<boolean>
abstract afterSync (existing: Issue, account: Ref<Account>, issueExternal: any, info: DocSyncInfo): Promise<void>
async handleDiffUpdate (
target: IssueSyncTarget,
existing: WithMarkup<Issue>,
info: DocSyncInfo,
issueData: GithubIssueData,
container: ContainerFocus,
issueExternal: IssueExternalData,
account: Ref<Account>,
accountGH: Ref<Account>,
syncToProject: boolean
): Promise<DocumentUpdate<DocSyncInfo>> {
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<Issue, GithubIssueP>(
existing._id as Ref<GithubIssueP>,
existing._class,
existing.space,
github.mixin.GithubIssue,
{
githubNumber: issueExternal.number,
url: issueExternal.url,
repository: info.repository as Ref<GithubIntegrationRepository>
}
)
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<GithubIntegrationRepository>
})
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<Issue, Issue>(
existing._id as Ref<GithubIssueP>,
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<Issue>(previousData, issueData, Object.keys(issueData))
const allAttributes = this.client.getHierarchy().getAllAttributes(container.project.mixinClass)
const platformUpdate = collectUpdate<Issue>(previousData, existingIssue, Array.from(allAttributes.keys()))
const okit = (await this.provider.getOctokit(account as Ref<PersonAccount>)) ?? 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, string> = {
[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<Issue>,
issueExternal: IssueExternalData
): Promise<void> {
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<Issue>,
platformUpdate: DocumentUpdate<Issue>,
issueData: Pick<WithMarkup<Issue>, 'title' | 'description' | 'assignee' | 'status'>,
container: ContainerFocus,
issueExternal: IssueExternalData,
_class: Ref<Class<Issue>>
): Promise<Record<string, any>> {
const issueUpdate: {
title?: string
body?: string
stateReason?: string
assigneeIds?: string[]
} & Record<string, any> = {}
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<void> => {
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<Class<Doc>>,
repo: GithubIntegrationRepository,
issues: IssueExternalData[],
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: _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<DocSyncInfo>(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<IssueSyncTarget | undefined | null> {
if (existingIssue !== undefined) {
// Select a milestone project
if (existingIssue.milestone != null) {
const milestone = (
await this.provider.liveQuery.queryFind<GithubMilestone>(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<GithubMilestone>
})
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<Account>, id: string): Promise<void>
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 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<PersonAccount>)) ?? 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<Class<Doc>> = tracker.class.Issue
): Promise<void> {
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<GithubProject>, {
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()
}
}
}