mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-25 17:42:57 +00:00
1177 lines
37 KiB
TypeScript
1177 lines
37 KiB
TypeScript
import { Analytics } from '@hcengineering/analytics'
|
|
import { PersonAccount } from '@hcengineering/contact'
|
|
import core, {
|
|
AnyAttribute,
|
|
Class,
|
|
Data,
|
|
Doc,
|
|
DocumentUpdate,
|
|
EnumOf,
|
|
MeasureContext,
|
|
Ref,
|
|
TxOperations,
|
|
generateId
|
|
} from '@hcengineering/core'
|
|
import github, {
|
|
DocSyncInfo,
|
|
GithubFieldMapping,
|
|
GithubIntegrationRepository,
|
|
GithubMilestone,
|
|
GithubProject,
|
|
GithubProjectSyncData
|
|
} from '@hcengineering/github'
|
|
import { getEmbeddedLabel, translate } from '@hcengineering/platform'
|
|
import { LiveQuery } from '@hcengineering/query'
|
|
import task from '@hcengineering/task'
|
|
import tracker, { Milestone } from '@hcengineering/tracker'
|
|
import { RepositoryEvent } from '@octokit/webhooks-types'
|
|
import { deepEqual } from 'fast-equals'
|
|
import { Octokit } from 'octokit'
|
|
import {
|
|
ContainerFocus,
|
|
DocSyncManager,
|
|
ExternalSyncField,
|
|
IntegrationContainer,
|
|
IntegrationManager,
|
|
githubExternalSyncVersion,
|
|
githubSyncVersion
|
|
} from '../types'
|
|
import {
|
|
GithubDataType,
|
|
GithubProjectV2,
|
|
GithubProjectV2Field,
|
|
GithubProjectV2ItemFieldValue,
|
|
IssueExternalData,
|
|
projectV2Field,
|
|
projectV2ItemFields,
|
|
supportedGithubTypes
|
|
} from './githubTypes'
|
|
import { syncConfig } from './syncConfig'
|
|
import {
|
|
collectUpdate,
|
|
errorToObj,
|
|
getPlatformType,
|
|
getType,
|
|
gqlp,
|
|
hashCode,
|
|
isGHWriteAllowed,
|
|
syncRunner
|
|
} from './utils'
|
|
|
|
const githubColors = ['GRAY', 'BLUE', 'GREEN', 'YELLOW', 'ORANGE', 'RED', 'PINK', 'PURPLE']
|
|
|
|
const categoryColors = {
|
|
[task.statusCategory.UnStarted]: githubColors[0],
|
|
[task.statusCategory.ToDo]: githubColors[3],
|
|
[task.statusCategory.Active]: githubColors[1],
|
|
[task.statusCategory.Won]: githubColors[2],
|
|
[task.statusCategory.Lost]: githubColors[7]
|
|
}
|
|
|
|
interface GithubMilestoneExternalData {
|
|
url: string
|
|
projectNumber: number
|
|
projectId: string
|
|
label: string
|
|
description: string
|
|
updatedAt: string
|
|
}
|
|
|
|
interface MilestoneData {
|
|
label: string
|
|
description: string
|
|
}
|
|
|
|
export class ProjectsSyncManager implements DocSyncManager {
|
|
provider!: IntegrationManager
|
|
|
|
externalDerivedSync = false
|
|
|
|
constructor (
|
|
readonly ctx: MeasureContext,
|
|
readonly client: TxOperations,
|
|
readonly lq: LiveQuery
|
|
) {}
|
|
|
|
async init (provider: IntegrationManager): Promise<void> {
|
|
this.provider = provider
|
|
}
|
|
|
|
async sync (
|
|
existing: Doc | undefined,
|
|
info: DocSyncInfo,
|
|
parent?: DocSyncInfo
|
|
): Promise<DocumentUpdate<DocSyncInfo> | undefined> {
|
|
const container = await this.provider.getContainer(info.space)
|
|
if (container?.container === undefined) {
|
|
return { needSync: githubSyncVersion }
|
|
}
|
|
|
|
const okit = await this.provider.getOctokit(container.project.createdBy as Ref<PersonAccount>)
|
|
if (okit === undefined) {
|
|
this.ctx.info('No Authentication for author, waiting for authentication.', {
|
|
workspace: this.provider.getWorkspaceId().name
|
|
})
|
|
return { needSync: githubSyncVersion, error: 'Need authentication for user' }
|
|
}
|
|
|
|
let checkStructure = false
|
|
|
|
if (
|
|
existing !== undefined &&
|
|
this.client.getHierarchy().isDerived(existing._class, tracker.class.Milestone) &&
|
|
container.container.type === 'Organization'
|
|
) {
|
|
// If no external project for milestone exists, let's create it.
|
|
const milestone = existing as Milestone
|
|
if (info.external === undefined) {
|
|
checkStructure = true
|
|
try {
|
|
await this.ctx.withLog(
|
|
'Create Milestone projectV2',
|
|
{},
|
|
() => this.createMilestone(container.container, container.project, okit, milestone, info),
|
|
{ label: milestone.label }
|
|
)
|
|
} catch (err: any) {
|
|
this.ctx.error('failed create milestone', err)
|
|
return { needSync: githubSyncVersion, error: errorToObj(err) }
|
|
}
|
|
}
|
|
|
|
if (checkStructure) {
|
|
const m = (await this.client.findOne(github.mixin.GithubMilestone, {
|
|
_id: milestone._id as Ref<GithubMilestone>
|
|
})) as GithubMilestone
|
|
|
|
let { projectStructure, wasUpdates } = await this.ctx.withLog(
|
|
'update project structure',
|
|
{},
|
|
() =>
|
|
syncRunner.exec(m._id, () =>
|
|
this.updateFieldMappings(container.container, container.project, m, container.project.mixinClass, okit)
|
|
),
|
|
{ label: milestone.label }
|
|
)
|
|
|
|
// Retrieve updated field
|
|
if (wasUpdates) {
|
|
projectStructure = (await this.ctx.withLog(
|
|
'update project structure(sync/second step)',
|
|
{},
|
|
() => this.queryProjectStructure(container.container, m),
|
|
{
|
|
label: m.label
|
|
}
|
|
)) as GithubProjectV2
|
|
}
|
|
container.container.projectStructure.set(m._id, projectStructure)
|
|
}
|
|
const milestoneExternal = info.external as GithubMilestoneExternalData
|
|
|
|
const messageData: MilestoneData = {
|
|
label: milestoneExternal.label,
|
|
description: await this.provider.getMarkup(container.container, milestoneExternal.description)
|
|
}
|
|
|
|
await this.handleDiffUpdateMilestone(existing, info, messageData, container, milestoneExternal)
|
|
|
|
return { current: messageData, needSync: githubSyncVersion }
|
|
}
|
|
|
|
return { needSync: githubSyncVersion }
|
|
}
|
|
|
|
private async createMilestone (
|
|
integration: IntegrationContainer,
|
|
project: GithubProject,
|
|
okit: Octokit,
|
|
milestone: Milestone,
|
|
info: DocSyncInfo | undefined
|
|
): Promise<void> {
|
|
if (integration.type !== 'Organization') {
|
|
return
|
|
}
|
|
const response = await this.createProjectV2(integration, okit, milestone.label)
|
|
|
|
if (response !== undefined) {
|
|
const data: GithubMilestoneExternalData = {
|
|
projectId: response.projectNodeId,
|
|
projectNumber: response.projectNumber,
|
|
url: response.url,
|
|
label: milestone.label,
|
|
description: '',
|
|
updatedAt: new Date().toISOString()
|
|
}
|
|
|
|
if (info !== undefined) {
|
|
info.external = data
|
|
}
|
|
|
|
await this.client.createMixin<Milestone, GithubMilestone>(
|
|
milestone._id,
|
|
milestone._class,
|
|
milestone.space,
|
|
github.mixin.GithubMilestone,
|
|
{
|
|
mappings: [],
|
|
url: response.url,
|
|
projectNodeId: response.projectNodeId,
|
|
projectNumber: response.projectNumber,
|
|
githubProjectName: milestone.label
|
|
}
|
|
)
|
|
|
|
const derivedClient = new TxOperations(this.client, core.account.System, true)
|
|
|
|
if (info !== undefined) {
|
|
await derivedClient.update(info, {
|
|
external: data,
|
|
needSync: ''
|
|
})
|
|
|
|
// We also need to notify all issues with milestone set to this milestone.
|
|
const milestonedIds = await this.client.findAll(
|
|
tracker.class.Issue,
|
|
{ milestone: milestone._id },
|
|
{ projection: { _id: 1 } }
|
|
)
|
|
while (milestonedIds.length > 0) {
|
|
const part = milestonedIds.splice(0, 100)
|
|
const docInfos = await this.client.findAll(
|
|
github.class.DocSyncInfo,
|
|
{ _id: { $in: part.map((it) => it._id as unknown as Ref<DocSyncInfo>) } },
|
|
{ projection: { _id: 1 } }
|
|
)
|
|
if (docInfos.length > 0) {
|
|
const ops = derivedClient.apply()
|
|
for (const d of docInfos) {
|
|
await ops.update(d, { needSync: '' })
|
|
}
|
|
await ops.commit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleDiffUpdateMilestone (
|
|
existing: Doc,
|
|
info: DocSyncInfo,
|
|
issueData: MilestoneData,
|
|
container: ContainerFocus,
|
|
issueExternal: GithubMilestoneExternalData
|
|
): Promise<DocumentUpdate<DocSyncInfo>> {
|
|
const existingMilestone = existing as Milestone
|
|
const previousData: MilestoneData = info.current ?? ({} as unknown as MilestoneData)
|
|
|
|
const update = collectUpdate<Milestone>(previousData, issueData, Object.keys(issueData))
|
|
|
|
const allAttributes = this.client.getHierarchy().getAllAttributes(tracker.class.Milestone)
|
|
const platformUpdate = collectUpdate<Milestone>(previousData, existingMilestone, Array.from(allAttributes.keys()))
|
|
|
|
const okit =
|
|
(await this.provider.getOctokit(existing.modifiedBy as Ref<PersonAccount>)) ?? container.container.octokit
|
|
|
|
// Remove current same values from update
|
|
for (const [k, v] of Object.entries(update)) {
|
|
if ((existingMilestone as any)[k] === v) {
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete (update as any)[k]
|
|
}
|
|
}
|
|
|
|
for (const [k, v] of Object.entries(update)) {
|
|
const pv = (platformUpdate as any)[k]
|
|
if (pv != null && pv !== v) {
|
|
// We have conflict of values.
|
|
this.ctx.error('conflict', { identifier: existingMilestone._id, k, v, pv })
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete (update as any)[k]
|
|
continue
|
|
}
|
|
}
|
|
|
|
if (container !== undefined && okit !== undefined) {
|
|
if (platformUpdate.label !== undefined || platformUpdate.description !== undefined) {
|
|
await this.updateProjectV2(okit, issueExternal.projectId, {
|
|
title: platformUpdate.label,
|
|
readme:
|
|
platformUpdate.description !== undefined
|
|
? await this.provider.getMarkdown(platformUpdate.description)
|
|
: undefined
|
|
})
|
|
}
|
|
}
|
|
|
|
if (Object.keys(update).length > 0) {
|
|
// We have some fields to update of existing from external
|
|
await this.client.update(existingMilestone, update, false, new Date(issueExternal.updatedAt).getTime())
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|
|
async handleEvent<T>(integration: IntegrationContainer, derivedClient: TxOperations, evt: T): Promise<void> {
|
|
const event = evt as RepositoryEvent
|
|
|
|
const { project, repository } = await this.provider.getProjectAndRepository(event.repository.node_id)
|
|
|
|
if (project === undefined || repository === undefined) {
|
|
this.ctx.error('Unable to find project and repository for event', {
|
|
name: event.repository.name,
|
|
workspace: this.provider.getWorkspaceId().name
|
|
})
|
|
return
|
|
}
|
|
|
|
if (project !== undefined) {
|
|
const projectStructure = (await this.ctx.withLog(
|
|
'update project structure(handleEvent)',
|
|
{ prj: project.name },
|
|
() => this.queryProjectStructure(integration, project)
|
|
)) as GithubProjectV2
|
|
|
|
integration.projectStructure.set(project._id, projectStructure)
|
|
}
|
|
}
|
|
|
|
async handleDelete (
|
|
existing: Doc | undefined,
|
|
info: DocSyncInfo,
|
|
derivedClient: TxOperations,
|
|
deleteExisting: boolean
|
|
): Promise<boolean> {
|
|
return false
|
|
}
|
|
|
|
async externalSync (
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations,
|
|
kind: ExternalSyncField,
|
|
syncDocs: DocSyncInfo[],
|
|
repository: GithubIntegrationRepository,
|
|
project: GithubProject
|
|
): Promise<void> {
|
|
for (const d of syncDocs) {
|
|
if (d.objectClass === tracker.class.Milestone) {
|
|
// no external data for doc
|
|
await derivedClient.update<DocSyncInfo>(d, {
|
|
externalVersion: githubExternalSyncVersion
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
repositoryDisabled (integration: IntegrationContainer, repo: GithubIntegrationRepository): void {
|
|
integration.synchronized.delete(`${repo._id}:issues`)
|
|
}
|
|
|
|
async externalFullSync (
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations,
|
|
projects: GithubProject[],
|
|
repositories: GithubIntegrationRepository[]
|
|
): Promise<void> {
|
|
for (const prj of projects) {
|
|
if (this.provider.isClosing()) {
|
|
break
|
|
}
|
|
// Wait global project sync
|
|
await integration.syncLock.get(prj._id)
|
|
|
|
const syncKey = `project_structure${prj._id}`
|
|
if (
|
|
prj === undefined ||
|
|
integration.synchronized.has(syncKey) ||
|
|
integration.octokit === undefined ||
|
|
integration.integration.createdBy === undefined
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const okit = await this.provider.getOctokit(integration.integration.createdBy as Ref<PersonAccount>)
|
|
if (okit === undefined) {
|
|
this.ctx.info('No Authentication for author, waiting for authentication.', {
|
|
workspace: this.provider.getWorkspaceId().name
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Check if project skill exists, on github
|
|
if (syncConfig.MainProject && integration.type === 'Organization') {
|
|
if (prj.projectNumber === undefined) {
|
|
try {
|
|
await this.ctx.withLog('Create projectV2', { prj: prj.name }, async () => {
|
|
const response = await this.createProjectV2(integration, okit, prj.name)
|
|
if (response !== undefined) {
|
|
prj.projectNumber = response.projectNumber
|
|
prj.projectNodeId = response.projectNodeId
|
|
|
|
await this.client.update(prj, response)
|
|
}
|
|
})
|
|
} catch (err: any) {
|
|
this.ctx.error('failed to create project', { prj: prj.name })
|
|
continue
|
|
}
|
|
}
|
|
|
|
try {
|
|
let { projectStructure, wasUpdates } = await this.ctx.withLog(
|
|
'update project structure',
|
|
{ prj: prj.name },
|
|
() => this.updateFieldMappings(integration, prj, prj, prj.mixinClass, okit)
|
|
)
|
|
|
|
// Check if we have any changes in project, during our inactivity.
|
|
await this.ctx.withLog('check project v2 changes:', { prj: prj.name }, () =>
|
|
this.checkChanges(projectStructure, prj, prj._id, integration, derivedClient)
|
|
)
|
|
|
|
// Retrieve updated field
|
|
if (wasUpdates) {
|
|
projectStructure = (await this.ctx.withLog('update project structure(second pass)', { prj: prj.name }, () =>
|
|
this.queryProjectStructure(integration, prj)
|
|
)) as GithubProjectV2
|
|
}
|
|
|
|
integration.projectStructure.set(prj._id, projectStructure)
|
|
} catch (err: any) {
|
|
this.ctx.error('filed to query project structure', err)
|
|
}
|
|
}
|
|
|
|
if (syncConfig.SupportMilestones && integration.type === 'Organization') {
|
|
// Check project milestones and sync their structure as well.
|
|
const milestones = await this.provider.liveQuery.findAll(github.mixin.GithubMilestone, {
|
|
space: prj._id
|
|
})
|
|
for (const m of milestones) {
|
|
if (this.provider.isClosing()) {
|
|
break
|
|
}
|
|
try {
|
|
let { projectStructure, wasUpdates } = await this.ctx.withLog(
|
|
'update project structure',
|
|
{ prj: m.label },
|
|
() =>
|
|
syncRunner.exec(
|
|
m._id,
|
|
async () => await this.updateFieldMappings(integration, prj, m, prj.mixinClass, okit)
|
|
)
|
|
)
|
|
|
|
// Check if we have any changes in project, during our inactivity.
|
|
await this.ctx.withLog('check project v2 changes', { prj: prj.name }, () =>
|
|
this.checkChanges(projectStructure, m, prj._id, integration, derivedClient)
|
|
)
|
|
|
|
// Retrieve updated field
|
|
if (wasUpdates) {
|
|
projectStructure = (await this.ctx.withLog(
|
|
'update project structure(second pass)',
|
|
{ prj: prj.name },
|
|
() => this.queryProjectStructure(integration, m)
|
|
)) as GithubProjectV2
|
|
}
|
|
|
|
integration.projectStructure.set(m._id, projectStructure)
|
|
} catch (err: any) {
|
|
this.ctx.error('filed to query project structure', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
integration.synchronized.add(syncKey)
|
|
}
|
|
}
|
|
|
|
private async checkChanges (
|
|
projectStructure: GithubProjectV2,
|
|
prj: GithubProject | GithubMilestone,
|
|
space: Ref<GithubProject>,
|
|
integration: IntegrationContainer,
|
|
derivedClient: TxOperations
|
|
): Promise<void> {
|
|
if (projectStructure.projectV2.updatedAt !== prj.githubUpdatedAt) {
|
|
// ok, we need to synchronize all project items.
|
|
const { query, params, root } = this.queryProject(integration, prj)
|
|
const i = integration.octokit.graphql.paginate.iterator(query, params)
|
|
|
|
// We need to collect a list of all uris of documents, and check if we have some missing ones.
|
|
const checkId = generateId()
|
|
try {
|
|
for await (const data of i) {
|
|
const items: {
|
|
id: string
|
|
type: string
|
|
updatedAt: string
|
|
fieldValues: {
|
|
nodes: GithubProjectV2ItemFieldValue[]
|
|
}
|
|
content: {
|
|
id: string
|
|
url: string
|
|
}
|
|
}[] = data[root].projectV2.items.nodes
|
|
|
|
const syncInfos = await this.client.findAll<DocSyncInfo>(github.class.DocSyncInfo, {
|
|
space,
|
|
objectClass: tracker.class.Issue,
|
|
url: { $in: items.map((it) => (it.content.url ?? '').toLowerCase()) }
|
|
})
|
|
|
|
for (const item of items) {
|
|
let needSync = false
|
|
const itemSyncData = syncInfos.find((it) => it.url === item.content.url.toLocaleLowerCase())
|
|
if (itemSyncData !== undefined) {
|
|
// We had item already, let's check our project field content and request update.
|
|
const external = itemSyncData.external as IssueExternalData
|
|
const dataIdx = external.projectItems.nodes.findIndex((it) => it.project.id === prj.projectNodeId)
|
|
if (dataIdx !== -1) {
|
|
const data = external.projectItems.nodes[dataIdx]
|
|
if (!deepEqual(data?.fieldValues.nodes, item.fieldValues.nodes)) {
|
|
// TODO: replace value
|
|
data.fieldValues = item.fieldValues
|
|
needSync = true
|
|
}
|
|
} else {
|
|
// No project information
|
|
needSync = true
|
|
}
|
|
// Mark all our existing sync documents, so we could find any missing ones.
|
|
await derivedClient.update(
|
|
itemSyncData,
|
|
needSync
|
|
? {
|
|
external: itemSyncData.external,
|
|
externalCheckId: checkId,
|
|
needSync: ''
|
|
}
|
|
: { externalCheckId: checkId }
|
|
)
|
|
}
|
|
}
|
|
|
|
this.provider.sync()
|
|
}
|
|
} catch (err: any) {
|
|
this.ctx.error('filed in checkChanges', err)
|
|
Analytics.handleError(err)
|
|
}
|
|
|
|
while (true) {
|
|
// Find all missing items
|
|
const missingInfos = await this.client.findAll<DocSyncInfo>(
|
|
github.class.DocSyncInfo,
|
|
{
|
|
space,
|
|
objectClass: tracker.class.Issue,
|
|
externalCheckId: { $ne: checkId },
|
|
targetNodeId: prj.projectNodeId,
|
|
external: { $exists: true } // Skip not created items yet
|
|
},
|
|
{ limit: 50 }
|
|
)
|
|
for (const u of missingInfos) {
|
|
// We need to sync
|
|
const udata = u.external as IssueExternalData
|
|
if (udata.projectItems !== undefined) {
|
|
udata.projectItems = {
|
|
nodes: (udata.projectItems?.nodes ?? []).filter((it) => it.project.id !== prj.projectNodeId)
|
|
}
|
|
}
|
|
await derivedClient.update(u, { needSync: '', external: u.external, externalCheckId: checkId })
|
|
}
|
|
if (missingInfos.length === 0) {
|
|
break
|
|
}
|
|
}
|
|
this.provider.sync()
|
|
|
|
await this.client.update(prj, {
|
|
githubUpdatedAt: projectStructure.projectV2.updatedAt
|
|
})
|
|
}
|
|
}
|
|
|
|
updateSet = new Map<string, Promise<void>>()
|
|
|
|
private async updateFieldMappings (
|
|
integration: IntegrationContainer,
|
|
prj: GithubProject,
|
|
target: GithubProject | GithubMilestone,
|
|
mixinClass: Ref<Class<Doc>>,
|
|
okit: Octokit
|
|
): Promise<{ projectStructure: GithubProjectV2, wasUpdates: boolean, mappings: GithubFieldMapping[] }> {
|
|
let projectStructure = await this.queryProjectStructure(integration, target)
|
|
let mappings = target.mappings
|
|
|
|
if (projectStructure === undefined) {
|
|
if (this.client.getHierarchy().isDerived(tracker.class.Project, target._class)) {
|
|
// We need to re-create project.
|
|
const project = target as GithubProject
|
|
await this.ctx.withLog(
|
|
'Create projectV2',
|
|
{ name: 'name' in target ? target.name : target.label },
|
|
async () => {
|
|
const response = await this.createProjectV2(integration, okit, project.name)
|
|
if (response !== undefined) {
|
|
target.projectNumber = response.projectNumber
|
|
target.projectNodeId = response.projectNodeId
|
|
}
|
|
|
|
mappings = []
|
|
await this.client.update(target, { ...response, mappings: [] })
|
|
}
|
|
)
|
|
} else {
|
|
const milestone = target as GithubMilestone
|
|
try {
|
|
await this.ctx.withLog('Create Milestone projectV2', { label: milestone.label }, async () => {
|
|
await this.createMilestone(integration, prj, okit, milestone, undefined)
|
|
mappings = []
|
|
})
|
|
} catch (err: any) {
|
|
Analytics.handleError(err)
|
|
this.ctx.error('Error', { err })
|
|
}
|
|
}
|
|
projectStructure = (await this.queryProjectStructure(integration, target)) as GithubProjectV2
|
|
}
|
|
|
|
const h = this.client.getHierarchy()
|
|
const allFields = h.getOwnAttributes(mixinClass)
|
|
|
|
const githubFields: GithubProjectV2Field[] = projectStructure.projectV2.fields.edges
|
|
|
|
const mHash = JSON.stringify(mappings)
|
|
// Create any platform field into matching github field
|
|
for (const [, f] of allFields.entries()) {
|
|
const existingField = (mappings ?? []).find((it) => it._id === f._id)
|
|
if (f.hidden === true) {
|
|
continue
|
|
}
|
|
if (f.isCustom === true && existingField === undefined) {
|
|
await this.createUpdateSimpleAttribute(f, githubFields, okit, target, mappings)
|
|
}
|
|
}
|
|
const statusF = h.getAttribute(tracker.class.Issue, 'status')
|
|
const f = await this.createUpdateStatus(githubFields, statusF, okit, target, prj)
|
|
if (f !== undefined) {
|
|
await this.pushMapping(target, mappings, statusF, f)
|
|
}
|
|
const priorityF = h.getAttribute(tracker.class.Issue, 'priority')
|
|
const pf = await this.createUpdatePriority(githubFields, priorityF, okit, target)
|
|
if (pf !== undefined) {
|
|
await this.pushMapping(target, mappings, priorityF, pf)
|
|
}
|
|
|
|
await this.createUpdateSimpleAttribute(
|
|
h.getAttribute(tracker.class.Issue, 'estimation'),
|
|
githubFields,
|
|
okit,
|
|
target,
|
|
mappings
|
|
)
|
|
|
|
await this.createUpdateSimpleAttribute(
|
|
h.getAttribute(tracker.class.Issue, 'reportedTime'),
|
|
githubFields,
|
|
okit,
|
|
target,
|
|
mappings
|
|
)
|
|
|
|
await this.createUpdateSimpleAttribute(
|
|
h.getAttribute(tracker.class.Issue, 'remainingTime'),
|
|
githubFields,
|
|
okit,
|
|
target,
|
|
mappings
|
|
)
|
|
|
|
for (const fieldNode of githubFields) {
|
|
const existingField = (target.mappings ?? []).find((it) => it.githubId === fieldNode.node.id)
|
|
if (existingField !== undefined) {
|
|
continue
|
|
}
|
|
|
|
if (supportedGithubTypes.has(fieldNode.node.dataType)) {
|
|
// try to find existing attribute
|
|
let matchedField: AnyAttribute | undefined
|
|
for (const [k, attr] of allFields) {
|
|
if (attr.type._class !== getPlatformType(fieldNode.node.dataType)) {
|
|
// Skip non matched fields.
|
|
continue
|
|
}
|
|
if (k.toLowerCase() === fieldNode.node.name.toLowerCase()) {
|
|
matchedField = attr
|
|
break
|
|
}
|
|
const labelValue = await translate(attr.label, {})
|
|
if (labelValue.toLowerCase() === fieldNode.node.name.toLowerCase()) {
|
|
matchedField = attr
|
|
break
|
|
}
|
|
}
|
|
|
|
if (matchedField !== undefined) {
|
|
// Ok we have field matched.
|
|
await this.pushMapping(
|
|
prj,
|
|
mappings,
|
|
{ _id: matchedField._id, name: matchedField.name, attributeOf: matchedField.attributeOf },
|
|
fieldNode
|
|
)
|
|
continue
|
|
}
|
|
|
|
if (fieldNode.node.dataType === 'SINGLE_SELECT') {
|
|
// TODO: Add enum update's
|
|
await this.createEnumAttribute(fieldNode, target, mappings, mixinClass)
|
|
} else if (fieldNode.node.dataType === 'NUMBER') {
|
|
await this.createSimpleAttribute(fieldNode, target, mappings, '0', mixinClass)
|
|
} else if (fieldNode.node.dataType === 'TEXT') {
|
|
await this.createSimpleAttribute(fieldNode, target, mappings, '', mixinClass)
|
|
} else if (fieldNode.node.dataType === 'DATE') {
|
|
await this.createSimpleAttribute(fieldNode, target, mappings, '', mixinClass)
|
|
} else if (fieldNode.node.dataType === 'ITERATION') {
|
|
// TODO: Handle Iteration data type.
|
|
}
|
|
}
|
|
}
|
|
return { projectStructure, wasUpdates: mHash !== JSON.stringify(target.mappings), mappings }
|
|
}
|
|
|
|
private async createUpdateSimpleAttribute (
|
|
field: AnyAttribute,
|
|
githubFields: GithubProjectV2Field[],
|
|
okit: Octokit,
|
|
target: GithubProject | GithubMilestone,
|
|
mappings: GithubFieldMapping[]
|
|
): Promise<void> {
|
|
const v = await this.createUpdateCustomField(githubFields, field, okit, target)
|
|
if (v !== undefined) {
|
|
await this.pushMapping(target, mappings, field, v)
|
|
}
|
|
}
|
|
|
|
private async createEnumAttribute (
|
|
fieldNode: GithubProjectV2Field,
|
|
prj: GithubProject | GithubMilestone,
|
|
mappings: GithubFieldMapping[],
|
|
mixinClass: Ref<Class<Doc>>
|
|
): Promise<void> {
|
|
const enumValues = (fieldNode.node.options ?? []).map((it) => it.name)
|
|
const enumId = await this.client.createDoc(core.class.Enum, core.space.Model, {
|
|
name: `Github_${'name' in prj ? prj.name : prj.label}_${fieldNode.node.name}`,
|
|
enumValues
|
|
})
|
|
const enumType: EnumOf = {
|
|
_class: core.class.EnumOf,
|
|
of: enumId,
|
|
label: getEmbeddedLabel(fieldNode.node.name)
|
|
}
|
|
const data: Data<AnyAttribute> = {
|
|
attributeOf: mixinClass,
|
|
name: fieldNode.node.id,
|
|
label: getEmbeddedLabel(fieldNode.node.name),
|
|
isCustom: true,
|
|
type: enumType,
|
|
defaultValue: enumValues[0]
|
|
}
|
|
// Create new attribute
|
|
const attrId = await this.client.createDoc(
|
|
core.class.Attribute,
|
|
core.space.Model,
|
|
data,
|
|
undefined,
|
|
Date.now(),
|
|
prj.createdBy
|
|
)
|
|
await this.pushMapping(prj, mappings, { _id: attrId, name: data.name, attributeOf: data.attributeOf }, fieldNode)
|
|
}
|
|
|
|
private async createSimpleAttribute (
|
|
fieldNode: GithubProjectV2Field,
|
|
prj: GithubProject | GithubMilestone,
|
|
mappings: GithubFieldMapping[],
|
|
defaultValue: string,
|
|
mixinClass: Ref<Class<Doc>>
|
|
): Promise<void> {
|
|
const data: Data<AnyAttribute> = {
|
|
attributeOf: mixinClass,
|
|
name: fieldNode.node.id, // Use github field id as name
|
|
label: getEmbeddedLabel(fieldNode.node.name),
|
|
isCustom: true,
|
|
type: {
|
|
_class: getPlatformType(fieldNode.node.dataType),
|
|
label: getEmbeddedLabel(fieldNode.node.name)
|
|
},
|
|
defaultValue
|
|
}
|
|
// Create new attribute
|
|
const attrId = await this.client.createDoc(
|
|
core.class.Attribute,
|
|
core.space.Model,
|
|
data,
|
|
undefined,
|
|
Date.now(),
|
|
prj.createdBy
|
|
)
|
|
await this.pushMapping(prj, mappings, { _id: attrId, name: data.name, attributeOf: data.attributeOf }, fieldNode)
|
|
}
|
|
|
|
private async pushMapping (
|
|
prj: GithubProject | GithubMilestone,
|
|
mappings: GithubFieldMapping[],
|
|
f: Pick<AnyAttribute, '_id' | 'name' | 'attributeOf'>,
|
|
node: GithubProjectV2Field
|
|
): Promise<void> {
|
|
const field = mappings.find((it) => it._id === f._id)
|
|
if (field !== undefined) {
|
|
return
|
|
}
|
|
const m = {
|
|
_id: f._id,
|
|
name: f.name,
|
|
_class: f.attributeOf,
|
|
githubId: node.node.id
|
|
}
|
|
mappings.push(m)
|
|
await this.client.update(prj, {
|
|
$push: {
|
|
mappings: m
|
|
}
|
|
})
|
|
}
|
|
|
|
private async createUpdateStatus (
|
|
githubFields: GithubProjectV2Field[],
|
|
statusAttr: AnyAttribute,
|
|
okit: Octokit,
|
|
prj: GithubProject | GithubMilestone,
|
|
project: GithubProject
|
|
): Promise<GithubProjectV2Field | undefined> {
|
|
// TODO: A support of field upgrade
|
|
const githubAttr = githubFields
|
|
.map((it) => it)
|
|
.find(
|
|
(it) =>
|
|
it.node.name.toLowerCase() === 'uber' + statusAttr.name.toLowerCase() && it.node.dataType === 'SINGLE_SELECT'
|
|
)
|
|
if (githubAttr !== undefined) {
|
|
return githubAttr
|
|
}
|
|
|
|
// Let's find all platform status fields
|
|
const statusFields = await this.provider.getProjectStatuses(project.type)
|
|
|
|
const opts: { name: string, color: string, description: string }[] = []
|
|
for (const fi of statusFields) {
|
|
opts.push({
|
|
name: fi.name,
|
|
description: fi.description ?? '',
|
|
color: categoryColors[fi.category ?? task.statusCategory.UnStarted]
|
|
})
|
|
}
|
|
|
|
if (isGHWriteAllowed()) {
|
|
const fieldUpdateResponse: any = await okit.graphql(
|
|
`mutation createProjectField {
|
|
${this.addProjectField(
|
|
prj.projectNodeId as string,
|
|
'Uber' + statusAttr.name[0].toUpperCase() + statusAttr.name.slice(1),
|
|
'SINGLE_SELECT',
|
|
opts
|
|
)}
|
|
}
|
|
`
|
|
)
|
|
return { node: fieldUpdateResponse.createProjectV2Field.projectV2Field as GithubProjectV2Field['node'] }
|
|
}
|
|
}
|
|
|
|
private async createUpdateCustomField (
|
|
githubFields: GithubProjectV2Field[],
|
|
attr: AnyAttribute,
|
|
okit: Octokit,
|
|
prj: GithubProject | GithubMilestone
|
|
): Promise<GithubProjectV2Field | undefined> {
|
|
const attrType = getType(attr)
|
|
if (attrType === undefined) {
|
|
return undefined
|
|
}
|
|
// TODO: A support of field upgrade
|
|
let githubAttr = githubFields
|
|
.map((it) => it)
|
|
.find((it) => it.node.name.toLowerCase() === attr.name.toLowerCase() && it.node.dataType === attrType)
|
|
if (githubAttr !== undefined) {
|
|
return githubAttr
|
|
}
|
|
|
|
// Find using label
|
|
|
|
const labelValue = await translate(attr.label, {})
|
|
|
|
githubAttr = githubFields
|
|
.map((it) => it)
|
|
.find((it) => it.node.name.toLowerCase() === labelValue.toLowerCase() && it.node.dataType === attrType)
|
|
|
|
if (githubAttr !== undefined) {
|
|
return githubAttr
|
|
}
|
|
|
|
let opts: { name: string, color: string, description: string }[] | undefined
|
|
|
|
if (attrType === 'SINGLE_SELECT') {
|
|
const typeOf = attr.type as EnumOf
|
|
const enumClass = await this.client.findOne(core.class.Enum, { _id: typeOf.of })
|
|
opts = []
|
|
for (const fi of enumClass?.enumValues ?? []) {
|
|
opts.push({
|
|
name: fi,
|
|
description: '',
|
|
color: githubColors[Math.abs(hashCode(fi)) % githubColors.length]
|
|
})
|
|
}
|
|
}
|
|
|
|
if (isGHWriteAllowed()) {
|
|
const fieldUpdateResponse: any = await okit.graphql(
|
|
`mutation createProjectField {
|
|
${this.addProjectField(
|
|
prj.projectNodeId as string,
|
|
labelValue[0].toUpperCase() + labelValue.slice(1),
|
|
attrType,
|
|
opts
|
|
)}
|
|
}
|
|
`
|
|
)
|
|
return { node: fieldUpdateResponse.createProjectV2Field.projectV2Field as GithubProjectV2Field['node'] }
|
|
}
|
|
}
|
|
|
|
private async createUpdatePriority (
|
|
githubFields: GithubProjectV2Field[],
|
|
attr: AnyAttribute,
|
|
okit: Octokit,
|
|
prj: GithubProject | GithubMilestone
|
|
): Promise<GithubProjectV2Field | undefined> {
|
|
// TODO: A support of field upgrade
|
|
const githubAttr = githubFields
|
|
.map((it) => it)
|
|
.find((it) => it.node.name.toLowerCase() === attr.name.toLowerCase() && it.node.dataType === 'SINGLE_SELECT')
|
|
if (githubAttr !== undefined) {
|
|
return githubAttr
|
|
}
|
|
|
|
const opts: { name: string, color: string, description: string }[] = []
|
|
|
|
for (const fi of ['Urgent', 'High', 'Medium', 'Low']) {
|
|
opts.push({
|
|
name: fi,
|
|
description: '',
|
|
color: githubColors[Math.abs(hashCode(fi)) % githubColors.length]
|
|
})
|
|
}
|
|
if (isGHWriteAllowed()) {
|
|
const fieldUpdateResponse: any = await okit.graphql(
|
|
`mutation createProjectField {
|
|
${this.addProjectField(
|
|
prj.projectNodeId as string,
|
|
attr.name[0].toUpperCase() + attr.name.slice(1),
|
|
'SINGLE_SELECT',
|
|
opts
|
|
)}
|
|
}
|
|
`
|
|
)
|
|
return { node: fieldUpdateResponse.createProjectV2Field.projectV2Field as GithubProjectV2Field['node'] }
|
|
}
|
|
}
|
|
|
|
private async queryProjectStructure (
|
|
integration: IntegrationContainer,
|
|
prj: GithubProjectSyncData
|
|
): Promise<GithubProjectV2 | undefined> {
|
|
const root = `${integration.type === 'Organization' ? 'organization' : 'user'}`
|
|
return (
|
|
(await integration.octokit?.graphql(
|
|
`
|
|
query projectStructureQuery($login: String!, $prjNumber: Int!) {
|
|
${root}(login: $login) {
|
|
projectV2(number: $prjNumber) {
|
|
id
|
|
updatedAt
|
|
title
|
|
readme
|
|
fields(last: 100) {
|
|
edges {
|
|
node {
|
|
${projectV2Field}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
{
|
|
login: integration.login,
|
|
prjNumber: prj.projectNumber
|
|
}
|
|
)) as any
|
|
)[root]
|
|
}
|
|
|
|
private queryProject (
|
|
integration: IntegrationContainer,
|
|
prj: GithubProjectSyncData
|
|
): { query: string, params: any, root: string } {
|
|
const root = `${integration.type === 'Organization' ? 'organization' : 'user'}`
|
|
return {
|
|
query: `
|
|
query queryProjectContents($login: String!, $prjNumber: Int!, $cursor: String) {
|
|
${root}(login: $login) {
|
|
projectV2(number: $prjNumber) {
|
|
items(first: 99, after: $cursor) {
|
|
nodes {
|
|
id
|
|
type
|
|
updatedAt
|
|
fieldValues(first: 50) {
|
|
nodes {
|
|
${projectV2ItemFields}
|
|
}
|
|
}
|
|
content {
|
|
... on Issue {
|
|
id
|
|
url
|
|
}
|
|
... on PullRequest {
|
|
id
|
|
url
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
startCursor
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
params: {
|
|
login: integration.login,
|
|
prjNumber: prj.projectNumber
|
|
},
|
|
root
|
|
}
|
|
}
|
|
|
|
private async createProjectV2 (
|
|
integration: IntegrationContainer,
|
|
octokit: Octokit,
|
|
prjName: string
|
|
): Promise<{ projectNumber: number, projectNodeId: string, url: string } | undefined> {
|
|
if (isGHWriteAllowed()) {
|
|
const response: any = await octokit.graphql(
|
|
`
|
|
mutation createProjectV2($owner: ID!, $title: String!) {
|
|
createProjectV2(input: {ownerId: $owner, title: $title}) {
|
|
projectV2 {
|
|
url
|
|
id
|
|
number
|
|
}
|
|
}
|
|
}`,
|
|
{
|
|
owner: integration.loginNodeId,
|
|
title: prjName
|
|
}
|
|
)
|
|
|
|
return {
|
|
projectNumber: response.createProjectV2.projectV2.number,
|
|
projectNodeId: response.createProjectV2.projectV2.id,
|
|
url: response.createProjectV2.projectV2.url
|
|
}
|
|
}
|
|
}
|
|
|
|
private async updateProjectV2 (
|
|
octokit: Octokit,
|
|
projectId: string,
|
|
options: {
|
|
title?: string
|
|
shortDescription?: string
|
|
readme?: string
|
|
}
|
|
): Promise<void> {
|
|
if (isGHWriteAllowed()) {
|
|
await octokit.graphql(
|
|
`
|
|
mutation createProjectV2($projectID: ID!) {
|
|
updateProjectV2(input: {
|
|
projectId: $projectID
|
|
${gqlp(options)}
|
|
}) {
|
|
projectV2 {
|
|
url
|
|
id
|
|
number
|
|
}
|
|
}
|
|
}`,
|
|
{
|
|
projectID: projectId
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private addProjectField (
|
|
projectId: string,
|
|
name: string,
|
|
type: GithubDataType,
|
|
options?: {
|
|
name: string
|
|
color: string
|
|
description: string
|
|
}[]
|
|
): string {
|
|
return `
|
|
createProjectV2Field(
|
|
input: {
|
|
dataType: ${type},
|
|
name: "${name}",
|
|
${
|
|
options !== undefined
|
|
? `singleSelectOptions: [
|
|
${options
|
|
.map((it) => `{name: "${it.name}", color: ${it.color}, description: "${it.description}"}`)
|
|
.join(', ')}], `
|
|
: ''
|
|
}
|
|
projectId: "${projectId}"
|
|
}
|
|
) {
|
|
projectV2Field {
|
|
${projectV2Field}
|
|
}
|
|
}
|
|
\n`
|
|
}
|
|
}
|