Migrate Tempo Cloud accounts from one instance to another


Get Started

Not the template you're looking for? Browse more.

About the template


This integration migrates Tempo accounts from one Cloud instance to another. Get started to learn more.

About ScriptRunner Connect


What is ScriptRunner Connect?

ScriptRunner Connect is an AI assisted code-first (JavaScript/TypeScript) integration platform (iPaaS) for building complex integrations and automations.

Can I try it out for free?

Yes. ScriptRunner Connect comes with a forever free tier.

Can I customize the integration logic?

Absolutely. The main value proposition of ScriptRunner Connect is that you'll get full access to the code that is powering the integration, which means you can make any changes to the the integration logic yourself.

Can I change the integration to communicate with additional apps?

Yes. Since ScriptRunner Connect specializes in enabling complex integrations, you can easily change the integration logic to connect to as many additional apps as you need, no limitations.

What if I don't feel comfortable making changes to the code?

First you can try out our AI assistant which can help you understand what the code does, and also help you make changes to the code. Alternatively you can hire our professionals to make the changes you need or build new integrations from scratch.

Do I have to host it myself?

No. ScriptRunner Connect is a fully managed SaaS (Software-as-a-Service) product.

What about security?

ScriptRunner Connect is ISO 27001 and SOC 2 certified. Learn more about our security.

Template Content


README

Scripts

TypeScriptClearStorage-Optional

README


Overview

This template migrates Tempo Cloud accounts from one Cloud instance to another. There are 2 ways to perform migration:

  • Full migration will remove all accounts from target instance prior the migration;
  • Exclusive migration will skip migration for accounts with the same key that already exist in target instance.

Following scripts are included:

  • MigrateAccounts - Run this script to start the migration process.

Workspace setup

Setup source and target Tempo Cloud and Jira Cloud connectors.

Migration logic

When you run the migration script, following will happen:

  • It will retrieve necessary existing data from target instance;
  • Will retrieve accounts from source instance;
  • Depending on chosen mode, accounts will be copied to target instance.
  • Lead field is required for accounts, so Jira User that assigned as Lead in source instance should be presented in target instance or the error will be thrown.
  • If Contact for account is Jira User that's not presented in target instance, the script will put their name as external contact.
  • If there is no such Customer or Category in target instance, it will be created.
  • Depending on chosen mode, after migration, links to Jira Projects will be added to migrated accounts if target instace has projects with the same key as in source instance.

Running the migration

To start the migration, open Parameters. You'll see DELETE_TARGET_ACCOUNTS variable, value of which you can change to choose the mode of migration.

Also there is LINK_PROJECTS_TO_MIGRATED_ACCOUNTS variable, value of which you can change to choose if you need to add links to Jira Projects for migrated accounts or not. Please note, if connectors owner doesn't have permissions to browse certain projects, that projects won't be linked to migrated accounts.

Then, run the MigrateAccounts script manually. Once the migration process is underway, you can abort at any time, by clicking on the Abort Invocation button for the message that you received in the console when you triggered your migration script. Do not clear the console, otherwise you will lose the option to abort the process.

Keeping an eye on the migration progress

Bunch of information is outputted to the console while the integration is running.

Re-trying failed accounts

If an error occured during full migration process, you can change DELETE_TARGET_ACCOUNTS in Parameters to false and run the script again so it will migrate the rest of the accounts that didn't migrate the first time.

(Optional) Supplementary scripts for large amount of accounts (2000+)

If the regular migration script invocation times out due to a large number of accounts in the source Tempo Cloud instance, you can choose to use supplementary scripts (marked as -Optional). Optional scripts use constant values from the MigrateAccounts script but execute deletion and migration logic separately and in batches. Also, if the current invocation is about to expire, it will automatically trigger a new invocation to continue the deletion or migration process.

  • If you need to delete all accounts in the target Tempo Cloud instance, please run the DeleteAccountsInBatches-Optional script.
  • Run MigrateAccountsInBatches-Optional to initiate the migration process.
  • Make sure to run ClearStorage-Optional script before running any of the -Optional scripts for the second time.
  • In case of any unexpected errors, you can run ClearStorage-Optional and rerun the migration or deletion script, and it will continue from where it left off.

API Connections


./api/jira/cloud/source@managed-api/jira-cloud-v3-sr-connect
TypeScriptClearStorage-Optional

import { RecordStorage } from '@sr-connect/record-storage';
import { getEnvVars } from './Utils';

export default async function (event: any, context: Context): Promise<void> {
    const {
        Optional: {
            DELETE_DATA_KEY,
            MIGRATION_DATA_KEY
        }
    } = getEnvVars(context);

    const storage = new RecordStorage();
    console.log('Clearing record storage...');
    await storage.deleteValue(DELETE_DATA_KEY);
    await storage.deleteValue(MIGRATION_DATA_KEY);
    console.log('Record storage cleared.');
}
TypeScriptDeleteAccountsInBatches-Optional

import TempoCloudTarget from "./api/tempo/cloud/target";
import { getAccounts, getLinks } from './MigrateAccounts';
import { throttleAll } from 'promise-throttle-all';
import { triggerScript } from '@sr-connect/trigger';
import { AccountAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/AccountAsResponse";
import { retry } from '@managed-api/commons-core';
import { RecordStorage } from '@sr-connect/record-storage';
import { getEnvVars } from './Utils';

export default async function (event: any, context: Context): Promise<void> {
    const { TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES,
        Optional: {
            DELETE_DATA_KEY,
        }
    } = getEnvVars(context);
    let targetAccounts: AccountAsResponse[];
    console.log('Retrieving existing data from target instance...');
    const storage = new RecordStorage();
    targetAccounts = await storage.getValue<AccountAsResponse[]>(DELETE_DATA_KEY);
    if (!targetAccounts) {
        targetAccounts = await getAccounts(TempoCloudTarget, TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES);
        await storage.setValue(DELETE_DATA_KEY, targetAccounts);
    }

    while (true) {
        if (!targetAccounts.length) {
            console.log('No accounts retrieved from target instance.');
            return;
        }
        const time = Date.now();
        console.log('Deleting accounts from target instance...');
        const batch = [];
        for (let i = 0; i < TEMPO_API_CONCURRENCY; i++) {
            if (targetAccounts[i]) {
                batch.push(targetAccounts[i]);
            } else {
                break;
            }
        }
        const results = await throttleAll(TEMPO_API_CONCURRENCY, batch.map(acc => () => deleteAccount(acc, TEMPO_CLOUD_API_PAGE_SIZE)));
        results.forEach(deletedAccount => targetAccounts.splice(targetAccounts.findIndex(acc => acc.key === deletedAccount.deletedAccountKey), 1));
        await storage.setValue(DELETE_DATA_KEY, targetAccounts);

        console.log(`Batch done`, {
            time: `${(Date.now() - time) / 1000} seconds`,
            accountsLeft: targetAccounts.length,
        });

        if (Date.now() > context.startTime + context.timeout - 60 * 1000) {
            await triggerScript('DeleteAccountsInBatches-Optional');
            break;
        }
    }
}

async function deleteAccount(acc: { key: string }, pageSize: number) {
    try {
        const accountLinks = await getLinks(TempoCloudTarget, acc, pageSize);

        for (const link of accountLinks) {
            await TempoCloudTarget.Account.Link.deleteLink({
                id: link.id,
                errorStrategy: {
                    handleHttp429Error: () => retry(1000),
                }
            });
        }

        await TempoCloudTarget.Account.deleteAccount({
            key: acc.key,
            errorStrategy: {
                handleHttp429Error: (response) => retry(1000),
            }
        });
    } catch (err) {
        throw new Error(`Failed to delete existing account in target instance: ${acc.key}. Error: ${(err as Error).message}`);
    }
    return {
        deletedAccountKey: acc.key,
    }
}
TypeScriptMigrateAccounts

import TempoCloudTarget from "./api/tempo/cloud/target";
import TempoCloudSource from "./api/tempo/cloud/source";
import JiraCloudTarget from "./api/jira/cloud/target";
import JiraCloudSource from "./api/jira/cloud/source";
import { TempoCloudApi } from '@managed-api/tempo-cloud-v4-sr-connect';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { throttleAll } from 'promise-throttle-all';
import { GetUsersResponseOK } from '@managed-api/jira-cloud-v3-core/types/user';
import { CustomerAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/CustomerAsResponse";
import { AccountAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/AccountAsResponse";
import { AccountLinkAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/AccountLinkAsResponse";
import { CategoryAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/CategoryAsResponse";
import { AccountInput } from "@managed-api/tempo-cloud-v4-core/definitions/AccountInput";
import { CategoryInput } from "@managed-api/tempo-cloud-v4-core/definitions/CategoryInput";
import { UserContactAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/UserContactAsResponse";
import { AccountSearchInput } from "@managed-api/tempo-cloud-v4-core/definitions/AccountSearchInput";
import { getEnvVars } from './Utils';

export const migratedAccounts: AccountInput[] = [];

export default async function (event: any, context: Context): Promise<void> {
    // Set global error strategies to retry rate limited requests infinitely
    TempoCloudSource.setGlobalErrorStrategy(builder => builder.retryOnRateLimiting(-1, 1000));
    TempoCloudTarget.setGlobalErrorStrategy(builder => builder.retryOnRateLimiting(-1, 1000));
    JiraCloudTarget.setGlobalErrorStrategy(builder => builder.retryOnRateLimiting(-1, 1000));
    JiraCloudSource.setGlobalErrorStrategy(builder => builder.retryOnRateLimiting(-1, 1000));

    const { TEMPO_API_CONCURRENCY, LINK_PROJECTS_TO_MIGRATED_ACCOUNTS, TEMPO_CLOUD_API_PAGE_SIZE, DELETE_TARGET_ACCOUNTS, STATUSES } = getEnvVars(context);

    console.log('Retrieving existing data from target instance...');
    const [targetUsers, targetCustomers, targetCategories, targetAccounts] = await Promise.all([
        getUsers(JiraCloudTarget),
        getCustomers(TempoCloudTarget, TEMPO_CLOUD_API_PAGE_SIZE),
        getCategories(TempoCloudTarget),
        getAccounts(TempoCloudTarget, TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES),
    ]);

    console.log('Retrieving accounts from source instance...');
    const sourceAccounts = await getAccounts(TempoCloudSource, TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES);
    console.log(`Retrieved accounts from source instance: ${sourceAccounts.length}`);

    if (!sourceAccounts.length) {
        console.log('No accounts retrieved from source instance.');
        return;
    }

    if (DELETE_TARGET_ACCOUNTS) {
        console.log('Deleting accounts from target instance...');
        await throttleAll(TEMPO_API_CONCURRENCY, targetAccounts.map(acc => () => deleteAccount(acc, TEMPO_CLOUD_API_PAGE_SIZE)));
        console.log('All accounts deleted from target instance.');

        const transformedAccounts = await prepareTargetInstanceAndUpdateAccounts(sourceAccounts, targetCategories, targetCustomers, targetUsers);
        console.log('Starting migration...');
        await throttleAll(TEMPO_API_CONCURRENCY, transformedAccounts.map(acc => () => copyAccount(acc)));
    } else {
        const filteredAccounts = sourceAccounts.filter(a => !targetAccounts.some(acc => acc.key === a.key));
        const transformedAccounts = await prepareTargetInstanceAndUpdateAccounts(filteredAccounts, targetCategories, targetCustomers, targetUsers);
        console.log('Starting migration...');
        await throttleAll(TEMPO_API_CONCURRENCY, transformedAccounts.map(acc => () => copyAccount(acc)));
    }

    console.log(`Migration complete! Accounts migrated: ${migratedAccounts.length}`);

    if (LINK_PROJECTS_TO_MIGRATED_ACCOUNTS) {
        console.log('Linking projects to migrated accounts...');
        await throttleAll(TEMPO_API_CONCURRENCY, migratedAccounts.map(acc => () => addLink(acc, TEMPO_CLOUD_API_PAGE_SIZE)));
    }
}

export async function getUsers(instance: JiraCloudApi) {
    const allUsers: GetUsersResponseOK = [];
    const maxResults = 50;
    let startAt = 0;

    do {
        const users = await instance.User.getUsers({
            startAt,
            maxResults
        });

        allUsers.push(...users);

        if (users.length === maxResults) {
            startAt += maxResults;
        } else {
            startAt = 0
        }
    } while (startAt > 0);

    return allUsers;
}

export async function getCustomers(instance: TempoCloudApi, pageSize: number) {
    const customers: CustomerAsResponse[] = [];
    let offset = 0;

    do {
        const result = await instance.Customer.getCustomers({
            limit: pageSize,
            offset
        });

        if (result.metadata.limit < pageSize) {
            console.warn(`TEMPO_CLOUD_API_PAGE_SIZE is larger than the actual allowed maximum: ${result.metadata.limit}`);
        }

        customers.push(...result.results);

        if (result.metadata.count === result.metadata.limit) {
            offset += result.metadata.limit;
        } else {
            offset = 0;
        }
    } while (offset > 0)

    return customers;
}

export async function getAccounts(instance: TempoCloudApi, apiConcurrency: number, pageSize: number, statuses: AccountSearchInput['statuses']) {
    const accounts: AccountAsResponse[] = [];
    let offset = 0;

    do {
        const result = await instance.Account.searchAccounts({
            limit: apiConcurrency,
            offset,
            body: {
                statuses,
            }
        });

        if (result.metadata.limit < pageSize) {
            console.warn(`TEMPO_CLOUD_API_PAGE_SIZE is larger than the actual allowed maximum: ${result.metadata.limit}`);
        }

        accounts.push(...result.results);

        if (result.metadata.count === result.metadata.limit) {
            offset += result.metadata.limit;
        } else {
            offset = 0;
        }
    } while (offset > 0)
    return accounts;
}

export async function getCategories(instance: TempoCloudApi) {
    const response = await instance.Account.Category.getAllCategoriesOrCategoryById();

    return response.results;
}

export async function prepareTargetInstanceAndUpdateAccounts(accounts: AccountAsResponse[], targetCategories: CategoryAsResponse[], targetCustomers: CustomerAsResponse[], targetUsers: GetUsersResponseOK) {
    const transformed: AccountInput[] = [];

    for (const acc of accounts) {
        if (acc.category) {
            const category = targetCategories.some(c => c.key === acc.category?.key);

            if (!category) {
                console.log(`Creating Category for target instance: ${acc.category.name}`);

                const createdCategory = await TempoCloudTarget.Account.Category.createCategory({
                    body: {
                        key: acc.category.key,
                        name: acc.category.name,
                        typeName: acc.category?.type?.name.toUpperCase() as CategoryInput['typeName'],
                    }
                });

                targetCategories.push(createdCategory);
            }
        }

        if (acc.customer) {
            const customer = targetCustomers.some(c => c.key === acc.customer?.key);

            if (!customer) {
                console.log(`Creating Customer for target instance: ${acc.customer.name}`);

                const createdCustomer = await TempoCloudTarget.Customer.createCustomer({
                    body: {
                        key: acc.customer.key,
                        name: acc.customer.name
                    }
                });

                targetCustomers.push(createdCustomer);
            }
        }

        let verifiedContact: Partial<UserContactAsResponse> | undefined;
        if (acc.contact) {
            verifiedContact = acc.contact;

            if (acc.contact.type === 'USER') {
                const user = targetUsers.some(u => u.accountId === acc.contact?.accountId);

                if (!user) {
                    const user = await JiraCloudSource.User.getUser({
                        accountId: acc.contact.accountId
                    });

                    //If Jira User is used as Contact for source Account, but not presented in target instance, their name will be added as external contact
                    verifiedContact = {
                        type: 'EXTERNAL',
                        displayName: user.displayName
                    }
                }
            }
        }

        const user = targetUsers.some(u => u.accountId === acc.lead.accountId);

        if (!user) {
            const sourceUser = await JiraCloudSource.User.getUser({ accountId: acc.lead.accountId });
            throw new Error(`Lead Jira User not found in target instance: ${sourceUser.displayName}`);
        }

        transformed.push({
            categoryKey: acc?.category?.key,
            contactAccountId: verifiedContact?.type === 'USER' ? verifiedContact?.accountId : undefined,
            customerKey: acc?.customer?.key,
            externalContactName: verifiedContact?.type === 'EXTERNAL' ? verifiedContact?.displayName : undefined,
            global: acc.global,
            key: acc.key,
            leadAccountId: acc.lead.accountId,
            monthlyBudget: acc.monthlyBudget,
            name: acc.name,
            status: acc.status
        });
    }

    return transformed;
}

export async function copyAccount(account: AccountInput) {
    try {
        await TempoCloudTarget.Account.createAccount({
            body: account
        });

        migratedAccounts.push(account);
    } catch (e) {
        console.error('Failed to copy account', e, account);
    }
    return account;
}

async function deleteAccount(acc: { key: string }, pageSize: number) {
    try {
        const accountLinks = await getLinks(TempoCloudTarget, acc, pageSize);

        for (const link of accountLinks) {
            await TempoCloudTarget.Account.Link.deleteLink({ id: link.id });
        }

        await TempoCloudTarget.Account.deleteAccount(acc);
    } catch (err) {
        throw new Error(`Failed to delete existing account in target instance: ${acc.key}. Error: ${(err as Error).message}`);
    }
}

export async function addLink(account: AccountInput, pageSize: number) {
    const links = await getLinks(TempoCloudSource, account, pageSize);
    for (const link of links) {
        const sourceProject = await JiraCloudSource.Project.getProject({
            projectIdOrKey: link.scope.id.toString()
        });

        const targetProject = await JiraCloudTarget.Project.getProject({
            projectIdOrKey: sourceProject.key ?? '',
            errorStrategy: {
                handleHttp404Error: () => null
            }
        });

        if (targetProject && targetProject.id) {
            await TempoCloudTarget.Account.Link.createLink({
                body: {
                    accountKey: account.key,
                    scopeId: +targetProject.id,
                    default: link.default,
                    scopeType: 'PROJECT'
                }
            });

            console.log(`Project ${targetProject.key} linked to account: ${account.name}`);
        }
    }
}

export async function getLinks(instance: TempoCloudApi, account: { key: string }, pageSize: number) {
    const links: AccountLinkAsResponse[] = [];
    let offset = 0;

    do {
        const result = await instance.Account.getLinks({ key: account.key, offset, limit: pageSize });

        if (result.metadata.limit < pageSize) {
            console.warn(`TEMPO_CLOUD_API_PAGE_SIZE is larger than the actual allowed maximum: ${result.metadata.limit}`);
        }

        links.push(...result.results);

        if (result.metadata.count === result.metadata.limit) {
            offset += result.metadata.limit;
        } else {
            offset = 0;
        }
    } while (offset > 0)

    return links;
}
TypeScriptMigrateAccountsInBatches-Optional

import TempoCloudTarget from "./api/tempo/cloud/target";
import TempoCloudSource from "./api/tempo/cloud/source";
import JiraCloudTarget from "./api/jira/cloud/target";
import { getUsers, getAccounts, getCategories, getCustomers, prepareTargetInstanceAndUpdateAccounts, copyAccount, addLink, migratedAccounts } from './MigrateAccounts';
import { throttleAll } from 'promise-throttle-all';
import { triggerScript } from '@sr-connect/trigger';
import { RecordStorage } from '@sr-connect/record-storage';
import { AccountAsResponse } from "@managed-api/tempo-cloud-v4-core/definitions/AccountAsResponse";
import { getEnvVars } from './Utils';

export default async function (event: any, context: Context): Promise<void> {
    const { TEMPO_API_CONCURRENCY, LINK_PROJECTS_TO_MIGRATED_ACCOUNTS, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES,
        Optional: {
            MIGRATION_DATA_KEY
        }
    } = getEnvVars(context);
    console.log('Retrieving existing data from target instance...');
    const [targetUsers, targetCustomers, targetCategories, targetAccounts] = await Promise.all([
        getUsers(JiraCloudTarget),
        getCustomers(TempoCloudTarget, TEMPO_CLOUD_API_PAGE_SIZE),
        getCategories(TempoCloudTarget),
        getAccounts(TempoCloudTarget, TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES),
    ]);
    const storage = new RecordStorage();
    // Retrieve data from Record Storage
    let filteredAccounts = await storage.getValue<AccountAsResponse[]>(MIGRATION_DATA_KEY);
    // Check if data is stored, otherwise request the data from source instance
    if (!filteredAccounts) {
        console.log('Retrieving existing data from source instance...');
        const sourceAccounts = await getAccounts(TempoCloudSource, TEMPO_API_CONCURRENCY, TEMPO_CLOUD_API_PAGE_SIZE, STATUSES);
        if (!sourceAccounts.length) {
            console.log('No accounts retrieved from source instance.');
            return;
        }
        filteredAccounts = sourceAccounts.filter(a => !targetAccounts.some(acc => acc.key === a.key));
        console.log(`Accounts from source instance filtered for migration: ${filteredAccounts.length}`);
        await storage.setValue(MIGRATION_DATA_KEY, filteredAccounts);
    }
    // Migrate accounts in batches equal to TEMPO_API_CONCURRENCY rate
    while (true) {
        const time = Date.now();
        if (!filteredAccounts.length) {
            console.log('No accounts left to migrate.');
            break;
        }
        const batch = [];
        for (let i = 0; i < TEMPO_API_CONCURRENCY; i++) {
            if (filteredAccounts[i]) {
                batch.push(filteredAccounts[i]);
            } else {
                break;
            }
        }
        const transformedAccounts = await prepareTargetInstanceAndUpdateAccounts(batch, targetCategories, targetCustomers, targetUsers);
        console.log('Starting migration...');
        const results = await throttleAll(TEMPO_API_CONCURRENCY, transformedAccounts.map(acc => () => copyAccount(acc)));
        // Exclude successfully migrated accounts from the list
        results.forEach(migratedAccount => filteredAccounts.splice(filteredAccounts.findIndex(acc => acc.key === migratedAccount.key), 1));
        // Store updated list of accounts
        await storage.setValue(MIGRATION_DATA_KEY, filteredAccounts);

        if (LINK_PROJECTS_TO_MIGRATED_ACCOUNTS) {
            console.log('Linking projects to migrated accounts...');
            await throttleAll(TEMPO_API_CONCURRENCY, batch.map(acc => () => addLink(acc, TEMPO_CLOUD_API_PAGE_SIZE)));
        }

        console.log(`Batch done`, {
            time: `${(Date.now() - time) / 1000} seconds`,
            migratedAccounts: migratedAccounts.length,
        });

        if (Date.now() > context.startTime + context.timeout - 60 * 1000) {
            console.log('No time left to run a next batch of accounts in this invocation, starting a new one.');
            await triggerScript('MigrateAccountsInBatches-Optional');
            break;
        }
    }

    console.log(`Migration complete! Accounts migrated: ${migratedAccounts.length}`);
}
TypeScriptUtils

import { AccountSearchInput } from "@managed-api/tempo-cloud-v4-core/definitions/AccountSearchInput";

interface EnvVars {
    TEMPO_API_CONCURRENCY: number;
    LINK_PROJECTS_TO_MIGRATED_ACCOUNTS: boolean;
    TEMPO_CLOUD_API_PAGE_SIZE: number;
    DELETE_TARGET_ACCOUNTS: boolean;
    STATUSES: AccountSearchInput['statuses'];
    Optional: {
        DELETE_DATA_KEY: string;
        MIGRATION_DATA_KEY: string;
    };
}

export function getEnvVars(context: Context) {
    return context.environment.vars as EnvVars;
}
Documentation · Support · Suggestions & feature requests