// // Copyright © 2022 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // // See the License for the specific language governing permissions and // limitations under the License. // import core, { Class, Doc, DocumentUpdate, DOMAIN_TX, generateId, Ref, SortingOrder, StatusCategory, TxCollectionCUD, TxCreateDoc, TxOperations, TxResult, TxUpdateDoc, DOMAIN_STATUS } from '@hcengineering/core' import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model' import { DOMAIN_SPACE } from '@hcengineering/model-core' import tags from '@hcengineering/tags' import { calcRank, genRanks, Issue, IssueStatus, IssueTemplate, IssueTemplateChild, Project, TimeReportDayType } from '@hcengineering/tracker' import { DOMAIN_TRACKER } from '.' import tracker from './plugin' enum DeprecatedIssueStatus { Backlog, Todo, InProgress, Done, Canceled } interface CreateProjectIssueStatusesArgs { tx: TxOperations projectId: Ref categories: StatusCategory[] defaultStatusId?: Ref defaultCategoryId?: Ref } const categoryByDeprecatedIssueStatus = { [DeprecatedIssueStatus.Backlog]: tracker.issueStatusCategory.Backlog, [DeprecatedIssueStatus.Todo]: tracker.issueStatusCategory.Unstarted, [DeprecatedIssueStatus.InProgress]: tracker.issueStatusCategory.Started, [DeprecatedIssueStatus.Done]: tracker.issueStatusCategory.Completed, [DeprecatedIssueStatus.Canceled]: tracker.issueStatusCategory.Canceled } as const async function createProjectIssueStatuses ({ tx, projectId: attachedTo, categories, defaultStatusId, defaultCategoryId = tracker.issueStatusCategory.Backlog }: CreateProjectIssueStatusesArgs): Promise { const issueStatusRanks = [...genRanks(categories.length)] for (const [i, statusCategory] of categories.entries()) { const { _id: category, defaultStatusName } = statusCategory const rank = issueStatusRanks[i] if (defaultStatusName !== undefined) { await tx.createDoc( tracker.class.IssueStatus, attachedTo, { ofAttribute: tracker.attribute.IssueStatus, name: defaultStatusName, category, rank }, category === defaultCategoryId ? defaultStatusId : undefined ) } } } async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { _id: tracker.project.DefaultProject }) const currentDeleted = await tx.findOne(core.class.TxRemoveDoc, { objectId: tracker.project.DefaultProject }) // Create new if not deleted by customers. if (current === undefined && currentDeleted === undefined) { const defaultStatusId: Ref = generateId() const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } }) await tx.createDoc( tracker.class.Project, core.space.Space, { name: 'Default', description: 'Default project', private: false, members: [], archived: false, identifier: 'TSK', sequence: 0, issueStatuses: 0, defaultIssueStatus: defaultStatusId, defaultTimeReportDay: TimeReportDayType.PreviousWorkDay, defaultAssignee: undefined }, tracker.project.DefaultProject ) await createProjectIssueStatuses({ tx, projectId: tracker.project.DefaultProject, categories, defaultStatusId }) } } async function fixProjectIssueStatusesOrder (tx: TxOperations, project: Project): Promise { const statuses = await tx.findAll( tracker.class.IssueStatus, { attachedTo: project._id }, { lookup: { category: core.class.StatusCategory } } ) statuses.sort((a, b) => (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)) const issueStatusRanks = genRanks(statuses.length) return statuses.map((status) => { const rank = issueStatusRanks.next().value if (rank === undefined || status.rank === rank) return undefined return tx.update(status, { rank }) }) } async function fixProjectsIssueStatusesOrder (tx: TxOperations): Promise { const projects = await tx.findAll(tracker.class.Project, {}) await Promise.all(projects.map((project) => fixProjectIssueStatusesOrder(tx, project))) } async function upgradeProjectSettings (tx: TxOperations): Promise { const projects = await tx.findAll(tracker.class.Project, { defaultTimeReportDay: { $exists: false } }) await Promise.all( projects.map((project) => tx.update(project, { defaultTimeReportDay: TimeReportDayType.PreviousWorkDay }) ) ) } async function upgradeProjectIssueStatuses (tx: TxOperations): Promise { const projects = await tx.findAll(tracker.class.Project, { issueStatuses: undefined }) if (projects.length > 0) { const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } }) for (const project of projects) { const defaultStatusId: Ref = generateId() await tx.update(project, { issueStatuses: 0, defaultIssueStatus: defaultStatusId }) await createProjectIssueStatuses({ tx, projectId: project._id, categories, defaultStatusId }) } } } async function upgradeIssueStatuses (tx: TxOperations): Promise { const deprecatedStatuses = [ DeprecatedIssueStatus.Backlog, DeprecatedIssueStatus.Canceled, DeprecatedIssueStatus.Done, DeprecatedIssueStatus.InProgress, DeprecatedIssueStatus.Todo ] const issues = await tx.findAll(tracker.class.Issue, { status: { $in: deprecatedStatuses as any } }) if (issues.length > 0) { const statusByDeprecatedStatus = new Map>() for (const issue of issues) { const deprecatedStatus = issue.status as unknown as DeprecatedIssueStatus if (!statusByDeprecatedStatus.has(deprecatedStatus)) { const category = categoryByDeprecatedIssueStatus[deprecatedStatus] const issueStatus = await tx.findOne(tracker.class.IssueStatus, { category }) if (issueStatus === undefined) { throw new Error(`Could not find a new status for "${DeprecatedIssueStatus[deprecatedStatus]}"`) } statusByDeprecatedStatus.set(deprecatedStatus, issueStatus._id) } await tx.update(issue, { status: statusByDeprecatedStatus.get(deprecatedStatus) }) } } } async function migrateParentIssues (client: MigrationClient): Promise { let { updated } = await client.update( DOMAIN_TRACKER, { _class: tracker.class.Issue, attachedToClass: { $exists: false } }, { subIssues: 0, collection: 'subIssues', attachedToClass: tracker.class.Issue } ) updated += ( await client.update( DOMAIN_TRACKER, { _class: tracker.class.Issue, parentIssue: { $exists: true } }, { $rename: { parentIssue: 'attachedTo' } } ) ).updated updated += ( await client.update( DOMAIN_TRACKER, { _class: tracker.class.Issue, attachedTo: { $in: [null, undefined] } }, { attachedTo: tracker.ids.NoParent } ) ).updated if (updated === 0) { return } const childrenCountById = new Map, number>() const parentIssueIds = ( await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, attachedTo: { $nin: [tracker.ids.NoParent] } }) ).map((issue) => issue.attachedTo) for (const issueId of parentIssueIds) { const count = childrenCountById.get(issueId) ?? 0 childrenCountById.set(issueId, count + 1) } for (const [_id, childrenCount] of childrenCountById) { await client.update(DOMAIN_TRACKER, { _id }, { subIssues: childrenCount }) } } async function updateIssueParentInfo (client: MigrationClient, parentIssue: Issue | null): Promise { const parents = parentIssue === null ? [] : [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents] const migrationResult = await client.update( DOMAIN_TRACKER, { _class: tracker.class.Issue, attachedTo: parentIssue?._id ?? tracker.ids.NoParent, parents: { $exists: false } }, { parents } ) if (migrationResult.matched > 0) { const subIssues = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, attachedTo: parentIssue?._id ?? tracker.ids.NoParent, subIssues: { $gt: 0 } }) for (const issue of subIssues) { await updateIssueParentInfo(client, issue) } } } async function migrateIssueParentInfo (client: MigrationClient): Promise { await updateIssueParentInfo(client, null) } async function migrateIssueComponents (client: MigrationClient): Promise { const issues = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, component: { $exists: false } }) if (issues.length === 0) { return } for (const issue of issues) { await client.update(DOMAIN_TRACKER, { _id: issue._id }, { component: null }) } } async function createDefaults (tx: TxOperations): Promise { await createDefaultProject(tx) await createOrUpdate( tx, tags.class.TagCategory, tags.space.Tags, { icon: tags.icon.Tags, label: 'Other', targetClass: tracker.class.Issue, tags: [], default: true }, tracker.category.Other ) } async function fillRank (client: MigrationClient): Promise { const docs = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, rank: '' }) let last = ( await client.find( DOMAIN_TRACKER, { _class: tracker.class.Issue, rank: { $ne: '' } }, { sort: { rank: SortingOrder.Descending }, limit: 1 } ) )[0] for (const doc of docs) { const rank = calcRank(last) await client.update( DOMAIN_TRACKER, { _id: doc._id }, { rank } ) await client.update( DOMAIN_TX, { 'tx.objectId': doc._id, 'tx._class': core.class.TxCreateDoc }, { 'tx.attributes.rank': rank } ) doc.rank = rank last = doc } } async function upgradeProjects (tx: TxOperations): Promise { await upgradeProjectIssueStatuses(tx) await fixProjectsIssueStatusesOrder(tx) await upgradeProjectSettings(tx) } async function upgradeIssues (tx: TxOperations): Promise { await upgradeIssueStatuses(tx) const issues = await tx.findAll(tracker.class.Issue, { $or: [{ blockedBy: { $exists: true } }, { relatedIssue: { $exists: true } }] }) for (const i of issues) { const rel = (i as any).relatedIssue as Ref[] const upd: DocumentUpdate = {} if (rel != null) { ;(upd as any).relatedIssue = null upd.relations = rel.map((it) => ({ _id: it, _class: tracker.class.Issue })) } if (i.blockedBy !== undefined) { if ((i.blockedBy as any[]).find((it) => typeof it === 'string') !== undefined) { upd.blockedBy = (i.blockedBy as unknown as Ref[]).map((it) => ({ _id: it, _class: tracker.class.Issue })) } } if (Object.keys(upd).length > 0) { await tx.update(i, upd) } } } async function renameProject (client: MigrationClient): Promise { await client.update( DOMAIN_TRACKER, { _class: { $in: [tracker.class.Issue, tracker.class.Sprint] }, project: { $exists: true } }, { $rename: { project: 'component' } } ) await client.update( DOMAIN_TRACKER, { _class: tracker.class.Project }, { _class: tracker.class.Component } ) const components = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Component }) for (const component of components) { await client.update( DOMAIN_TX, { objectId: component._id, objectClass: tracker.class.Project }, { objectClass: tracker.class.Component } ) } await client.update( DOMAIN_TX, { _class: core.class.TxCollectionCUD, 'tx._class': core.class.TxCreateDoc, 'tx.objectClass': tracker.class.Issue, 'tx.attributes.project': { $exists: true } }, { $rename: { 'tx.attributes.project': 'tx.attributes.component' } } ) await client.update( DOMAIN_TX, { _class: core.class.TxCollectionCUD, 'tx._class': core.class.TxUpdateDoc, 'tx.objectClass': tracker.class.Issue, 'tx.operations.project': { $exists: true } }, { $rename: { 'tx.operations.project': 'tx.operations.component' } } ) await client.update( DOMAIN_TX, { objectClass: tracker.class.Sprint, _class: core.class.TxCreateDoc, 'attributes.project': { $exists: true } }, { $rename: { 'attributes.project': 'attributes.component' } } ) await client.update( DOMAIN_TX, { objectClass: { $in: [tracker.class.Issue, tracker.class.Sprint] }, _class: core.class.TxUpdateDoc, 'operations.project': { $exists: true } }, { $rename: { 'operations.project': 'operations.component' } } ) const templates = await client.find(DOMAIN_TRACKER, { _class: tracker.class.IssueTemplate, project: { $exists: true } }) for (const template of templates) { const children: IssueTemplateChild[] = template.children.map((p) => { const res = { ...p, component: p.component } delete (res as any).project return res }) await client.update( DOMAIN_TRACKER, { _id: template._id }, { children } ) await client.update( DOMAIN_TRACKER, { _id: template._id }, { $rename: { project: 'component' } } ) const createTxes = await client.find>(DOMAIN_TX, { objectId: template._id, _class: core.class.TxCreateDoc }) for (const createTx of createTxes) { const children: IssueTemplateChild[] = createTx.attributes.children.map((p) => { const res = { ...p, component: p.component } delete (res as any).project return res }) await client.update>( DOMAIN_TX, { _id: createTx._id }, { children } ) await client.update( DOMAIN_TX, { _id: createTx._id }, { $rename: { 'attributes.project': 'attributes.component' } } ) } const updateTxes = await client.find>(DOMAIN_TX, { objectId: template._id, _class: core.class.TxUpdateDoc }) for (const updateTx of updateTxes) { if ((updateTx.operations as any).project !== undefined) { await client.update( DOMAIN_TX, { _id: updateTx._id }, { $rename: { 'operations.project': 'operations.component' } } ) } if (updateTx.operations.children !== undefined) { const children: IssueTemplateChild[] = updateTx.operations.children.map((p) => { const res = { ...p, component: p.component } delete (res as any).project return res }) await client.update( DOMAIN_TX, { _id: updateTx._id }, { children } ) } } } const defaultSpace = ( await client.find(DOMAIN_SPACE, { _id: 'tracker:team:DefaultTeam' as Ref }) )[0] if (defaultSpace !== undefined) { await client.delete(DOMAIN_SPACE, tracker.project.DefaultProject) await client.create(DOMAIN_SPACE, { ...defaultSpace, _id: tracker.project.DefaultProject, _class: tracker.class.Project, description: defaultSpace.description === 'Default team' ? 'Default project' : defaultSpace.description }) await client.delete(DOMAIN_SPACE, defaultSpace._id) } await client.update( DOMAIN_SPACE, { _id: 'tracker:team:DefaultTeam' as Ref, _class: 'tracker:class:Team' as Ref> }, { _id: tracker.project.DefaultProject, _class: tracker.class.Project, description: 'Default project' } ) await client.update( DOMAIN_TRACKER, { attachedTo: 'tracker:team:DefaultTeam' as Ref }, { attachedTo: tracker.project.DefaultProject } ) await client.update( DOMAIN_TRACKER, { space: 'tracker:team:DefaultTeam' as Ref }, { space: tracker.project.DefaultProject } ) await client.update( DOMAIN_TRACKER, { attachedToClass: 'tracker:class:Team' as Ref> }, { attachedToClass: tracker.class.Project } ) await client.update( DOMAIN_TX, { objectId: 'tracker:team:DefaultTeam' as Ref }, { objectId: tracker.project.DefaultProject } ) await client.update( DOMAIN_TX, { objectClass: 'tracker:class:Team' as Ref> }, { objectClass: tracker.class.Project } ) await client.update( DOMAIN_TX, { 'tx.objectClass': 'tracker:class:Team' as Ref> }, { 'tx.objectClass': tracker.class.Project } ) await client.update( DOMAIN_TX, { objectSpace: 'tracker:team:DefaultTeam' as Ref }, { objectSpace: tracker.project.DefaultProject } ) await client.update( DOMAIN_TX, { 'tx.objectSpace': 'tracker:team:DefaultTeam' as Ref }, { 'tx.objectSpace': tracker.project.DefaultProject } ) } async function setCreate (client: MigrationClient): Promise { while (true) { const docs = await client.find( DOMAIN_TRACKER, { _class: tracker.class.Issue, createOn: { $exists: false } }, { limit: 500 } ) if (docs.length === 0) { break } const creates = await client.find>(DOMAIN_TX, { 'tx.objectId': { $in: docs.map((it) => it._id) }, 'tx._class': core.class.TxCreateDoc }) for (const doc of docs) { const tx = creates.find((it) => it.tx.objectId === doc._id) if (tx !== undefined) { await client.update( DOMAIN_TRACKER, { _id: doc._id }, { createOn: tx.modifiedOn } ) await client.update( DOMAIN_TX, { _id: tx._id }, { 'tx.attributes.createOn': tx.modifiedOn } ) } else { await client.update( DOMAIN_TRACKER, { _id: doc._id }, { createOn: doc.modifiedOn } ) } } } } export const trackerOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await client.update( DOMAIN_TRACKER, { _class: tracker.class.Issue, reports: { $exists: false } }, { reports: 0, estimation: 0, reportedTime: 0 } ) await Promise.all([migrateIssueComponents(client), migrateParentIssues(client)]) await migrateIssueParentInfo(client) await fillRank(client) await renameProject(client) await setCreate(client) // Move all status objects into status domain await client.move( DOMAIN_TRACKER, { _class: tracker.class.IssueStatus }, DOMAIN_STATUS ) await client.update( DOMAIN_STATUS, { _class: tracker.class.IssueStatus, ofAttribute: { $exists: false } }, { ofAttribute: tracker.attribute.IssueStatus } ) }, async upgrade (client: MigrationUpgradeClient): Promise { const tx = new TxOperations(client, core.account.System) await createDefaults(tx) await upgradeProjects(tx) await upgradeIssues(tx) } }