Template Content
About the template
About ScriptRunner Connect
What is ScriptRunner Connect?
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
This template migrates Tempo Cloud accounts from one Cloud instance to another. There are 2 ways to perform migration:
Following scripts are included:
MigrateAccounts
- Run this script to start the migration process.Setup source and target Tempo Cloud and Jira Cloud connectors.
When you run the migration script, following will happen:
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.
Bunch of information is outputted to the console while the integration is running.
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.
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.
DeleteAccountsInBatches-Optional
script.MigrateAccountsInBatches-Optional
to initiate the migration process.ClearStorage-Optional
script before running any of the -Optional
scripts for the second time. ClearStorage-Optional
and rerun the migration or deletion script, and it will continue from where it left off.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.');
}
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,
}
}
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;
}
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}`);
}
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;
}