Template Content
Not the template you're looking for? Browse more.
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.
This integration migrates Tempo Cloud entities from a source instance to a target instance. It supports migrating skills, roles, programs, teams, team permission roles, team-project links, team members, and user skill assignments.
This template was developed with the assistance of an AI agent.
Migration process:
Business Value:
Create the following Connectors in ScriptRunner Connect web UI:
For detailed Connector setup instructions, see the Connectors documentation.
Create the following API Connections in your workspace:
For detailed API Connection setup instructions, see the API Connections documentation.
Configure the following Parameters in the ScriptRunner Connect web UI:
| Parameter Name | Type | Description | Example Value |
|---|---|---|---|
MIGRATE_SKILLS | Boolean | Whether to migrate skills from source to target. | true |
MIGRATE_ROLES | Boolean | Whether to migrate roles from source to target. | true |
MIGRATE_PROGRAMS | Boolean | Whether to migrate programs from source to target. | true |
MIGRATE_TEAMS | Boolean | Whether to migrate teams from source to target. | true |
MIGRATE_TEAM_PERMISSION_ROLES | Boolean | Whether to migrate team permission roles. Requires MIGRATE_TEAMS to be true. | true |
MIGRATE_TEAM_LINKS | Boolean | Whether to migrate team-project links. Requires MIGRATE_TEAMS to be true. | true |
MIGRATE_TEAM_MEMBERS | Boolean | Whether to migrate team members. Requires MIGRATE_TEAMS to be true. | true |
MIGRATE_USER_SKILL_ASSIGNMENTS | Boolean | Whether to migrate user skill assignments. Requires MIGRATE_TEAM_MEMBERS to be true. | true |
PUBLIC_TEAMS | Boolean | Whether migrated teams should be created as public teams. | false |
TEAM_MEMBER_INCLUSIVE_END_DATE | Boolean | Whether to use inclusive end dates when migrating team memberships. | true |
CONCURRENCY | Number | How many concurrent migration jobs to maintain. Increase for higher throughput, decrease if hitting rate limits. | 5 |
TEMPO_FAILURE_RETRY_ATTEMPTS | Number | How many times to retry when Tempo API returns 500 errors before giving up. Set to 0 to disable retries. | 3 |
Migration Order Dependencies:
Recommendation:
It is recommended to migrate all entity types by setting all MIGRATE_* parameters to true. The Parameters allow skipping some entity types, but this should only be used when:
RunMigration in the Resource Manager tree or code editor headerThe migration follows this sequence:
After migration completes:
The migration includes robust error handling:
TEMPO_FAILURE_RETRY_ATTEMPTS)CONCURRENCY parameterPROJECT type links are migrated (board links are skipped)The migration script logs warnings when individual operations fail. These warnings don't stop the migration but indicate issues that need attention:
Failed to create team [Team Name]: Error: 404 - User is invalid
Failed to migrate link for team [Team Name], project ID [ID]: NotFoundError: No project could be found with key '[KEY]'
Failed to create membership for team [Team Name], user [User Name] ([Account ID]): Error: 404 - User is invalid
Failed to create program [Program Name]: Error: 400 - Manager must be a Jira user
Failed to migrate skill assignments for user [User Name] ([Email]): Error: 400 - One or more skills from input are already assigned to this resource
MIGRATE_SKILLS to true. If skills already exist in target, delete existing skill assignments from the target instance before running the migration, or skip skill assignment migrationRate Limiting Errors:
CONCURRENCY parameter valueTempo 500 Server Errors:
TEMPO_FAILURE_RETRY_ATTEMPTS to allow more retriesTeam Links Not Migrating:
MIGRATE_TEAM_LINKS is true and teams were successfully migratedTeam Members Not Migrating:
MIGRATE_TEAM_MEMBERS is true and teams were successfully migratedUser Skill Assignments Not Migrating:
MIGRATE_USER_SKILL_ASSIGNMENTS is true and team members were successfully migratedTeam Permission Roles Not Migrating:
MIGRATE_TEAM_PERMISSION_ROLES is true and teams were successfully migratedimport { throttleAll } from 'promise-throttle-all';
import {
getSkills,
createSkill,
getRoles,
createRole,
getPrograms,
createProgram,
getTeams,
createTeam,
getTeamLinks,
createTeamLink,
createMembership,
getSkillAssignments,
createSkillAssignment,
getPermissionRoles,
createPermissionRole,
updatePermissionRole,
setTempoFailureRetryAttempts,
} from './Tempo';
import TempoSource from './api/tempo/cloud/source';
import TempoTarget from './api/tempo/cloud/target';
import JiraSource from './api/jira/cloud/source';
import JiraTarget from './api/jira/cloud/target';
// Cache for user lookups
const USER_CACHE = new Map<string, string>();
/**
* Main entry point for Tempo Teams migration.
* Orchestrates the migration of Tempo Cloud entities (skills, roles, programs, teams, permission roles,
* team links, team members, and user skill assignments) from source to target instance.
* Migration steps are controlled by environment variables (Parameters) and executed in the correct dependency order.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default async function (event: any, context: Context<EV>): Promise<void> {
const concurrency = context.environment.vars.CONCURRENCY;
setTempoFailureRetryAttempts(context.environment.vars.TEMPO_FAILURE_RETRY_ATTEMPTS);
if (context.environment.vars.MIGRATE_SKILLS) {
await migrateSkills(concurrency);
}
if (context.environment.vars.MIGRATE_ROLES) {
await migrateRoles(concurrency);
}
if (context.environment.vars.MIGRATE_PROGRAMS) {
await migratePrograms(concurrency);
}
if (context.environment.vars.MIGRATE_TEAMS) {
const migratedTeams = await migrateTeams(concurrency, context.environment.vars.PUBLIC_TEAMS);
if (migratedTeams.length > 0) {
if (context.environment.vars.MIGRATE_TEAM_PERMISSION_ROLES) {
await migrateTeamPermissionRoles(concurrency, migratedTeams);
}
if (context.environment.vars.MIGRATE_TEAM_LINKS) {
await migrateTeamLinks(concurrency, migratedTeams);
}
let migratedMembers: string[] = [];
if (context.environment.vars.MIGRATE_TEAM_MEMBERS) {
migratedMembers = await migrateTeamMembers(
concurrency,
migratedTeams,
context.environment.vars.TEAM_MEMBER_INCLUSIVE_END_DATE,
);
if (context.environment.vars.MIGRATE_USER_SKILL_ASSIGNMENTS && migratedMembers.length > 0) {
await migrateUserSkillAssignments(concurrency, migratedMembers);
}
}
}
}
}
/**
* Migrates skills from source Tempo instance to target instance.
* Only creates skills that don't already exist in the target (by name).
*/
async function migrateSkills(concurrency: number): Promise<void> {
console.log('Starting skills migration...');
// Get all skills from both instances
const [sourceSkills, targetSkills] = await Promise.all([getSkills(TempoSource), getSkills(TempoTarget)]);
console.log(`Found ${sourceSkills.length} skills in source instance`);
console.log(`Found ${targetSkills.length} skills in target instance`);
// Create a Set of target skill names for quick lookup
const targetSkillNames = new Set(targetSkills.map((skill) => skill.name));
// Filter skills that need to be created (don't exist in target)
const skillsToCreate = sourceSkills.filter((skill) => !targetSkillNames.has(skill.name));
const skippedSkills = sourceSkills.filter((skill) => targetSkillNames.has(skill.name));
if (skippedSkills.length > 0) {
console.log(
`Skipping ${skippedSkills.length} skills that already exist in target:`,
skippedSkills.map((s) => s.name).join(', '),
);
}
if (skillsToCreate.length === 0) {
console.log('No new skills to migrate');
return;
}
console.log(`Creating ${skillsToCreate.length} new skills in target instance`);
// Create tasks for concurrent execution
let successCount = 0;
const createTasks = skillsToCreate.map((skill) => async () => {
try {
await createSkill(skill.name);
successCount++;
} catch (error) {
console.warn(`Failed to create skill ${skill.name}:`, error);
}
});
// Execute creation tasks with concurrency limit
await throttleAll(concurrency, createTasks);
console.log(`Processed ${skillsToCreate.length} skills, successfully created ${successCount}`);
}
/**
* Migrates roles from source Tempo instance to target instance.
* Only creates roles that don't already exist in the target (by name).
*/
async function migrateRoles(concurrency: number): Promise<void> {
console.log('Starting roles migration...');
// Get all roles from both instances
const [sourceRoles, targetRoles] = await Promise.all([getRoles(TempoSource), getRoles(TempoTarget)]);
console.log(`Found ${sourceRoles.length} roles in source instance`);
console.log(`Found ${targetRoles.length} roles in target instance`);
// Create a Set of target role names for quick lookup
const targetRoleNames = new Set(targetRoles.map((role) => role.name));
// Filter roles that need to be created (don't exist in target)
const rolesToCreate = sourceRoles.filter((role) => !targetRoleNames.has(role.name));
const skippedRoles = sourceRoles.filter((role) => targetRoleNames.has(role.name));
if (skippedRoles.length > 0) {
console.log(
`Skipping ${skippedRoles.length} roles that already exist in target:`,
skippedRoles.map((r) => r.name).join(', '),
);
}
if (rolesToCreate.length === 0) {
console.log('No new roles to migrate');
return;
}
console.log(`Creating ${rolesToCreate.length} new roles in target instance`);
// Create tasks for concurrent execution
let successCount = 0;
const createTasks = rolesToCreate.map((role) => async () => {
try {
await createRole(role.name);
successCount++;
} catch (error) {
console.warn(`Failed to create role ${role.name}:`, error);
}
});
// Execute creation tasks with concurrency limit
await throttleAll(concurrency, createTasks);
console.log(`Processed ${rolesToCreate.length} roles, successfully created ${successCount}`);
}
/**
* Migrates programs from source Tempo instance to target instance.
* Only creates programs that don't already exist in the target (by name).
*/
async function migratePrograms(concurrency: number): Promise<void> {
console.log('Starting programs migration...');
// Get all programs from both instances
const [sourcePrograms, targetPrograms] = await Promise.all([getPrograms(TempoSource), getPrograms(TempoTarget)]);
console.log(`Found ${sourcePrograms.length} programs in source instance`);
console.log(`Found ${targetPrograms.length} programs in target instance`);
// Create a Set of target program names for quick lookup
const targetProgramNames = new Set(targetPrograms.map((program) => program.name));
// Filter programs that need to be created (don't exist in target)
const programsToCreate = sourcePrograms.filter((program) => !targetProgramNames.has(program.name));
const skippedPrograms = sourcePrograms.filter((program) => targetProgramNames.has(program.name));
if (skippedPrograms.length > 0) {
console.log(
`Skipping ${skippedPrograms.length} programs that already exist in target:`,
skippedPrograms.map((p) => p.name).join(', '),
);
}
if (programsToCreate.length === 0) {
console.log('No new programs to migrate');
return;
}
console.log(`Creating ${programsToCreate.length} new programs in target instance`);
// Create tasks for concurrent execution
let successCount = 0;
const createTasks = programsToCreate.map((program) => async () => {
try {
await createProgram(program.name, program.manager?.accountId);
successCount++;
} catch (error) {
console.warn(`Failed to create program ${program.name}:`, error);
}
});
// Execute creation tasks with concurrency limit
await throttleAll(concurrency, createTasks);
console.log(`Processed ${programsToCreate.length} programs, successfully created ${successCount}`);
}
/**
* Migrates teams from source Tempo instance to target instance.
* Only creates teams that don't already exist in the target (by name).
* Returns a list of teams that were migrated with their source and target IDs.
*/
async function migrateTeams(concurrency: number, isPublic: boolean): Promise<MigratedTeam[]> {
console.log('Starting teams migration...');
// Get all teams and programs from both instances
const [sourceTeams, targetTeams, targetPrograms] = await Promise.all([
getTeams(TempoSource),
getTeams(TempoTarget),
getPrograms(TempoTarget),
]);
console.log(`Found ${sourceTeams.length} teams in source instance`);
console.log(`Found ${targetTeams.length} teams in target instance`);
// Create a Map of target program names to program IDs for quick lookup
const targetProgramMap = new Map(targetPrograms.map((program) => [program.name, program.id]));
// Create a Set of target team names for quick lookup
const targetTeamNames = new Set(targetTeams.map((team) => team.name));
// Filter teams that need to be created (don't exist in target)
const teamsToCreate = sourceTeams.filter((team) => !targetTeamNames.has(team.name));
const skippedTeams = sourceTeams.filter((team) => targetTeamNames.has(team.name));
if (skippedTeams.length > 0) {
console.log(
`Skipping ${skippedTeams.length} teams that already exist in target:`,
skippedTeams.map((t) => t.name).join(', '),
);
}
if (teamsToCreate.length === 0) {
console.log('No new teams to migrate');
return [];
}
console.log(`Creating ${teamsToCreate.length} new teams in target instance`);
// Create tasks for concurrent execution that return both source team and target team ID
const createTasks = teamsToCreate.map((team) => async () => {
try {
// Find program ID in target instance by program name
const targetProgramId = team.program?.name ? targetProgramMap.get(team.program.name) : undefined;
const targetId = await createTeam(
team.name,
team.administrative,
isPublic,
team.summary,
team.lead?.accountId,
targetProgramId,
);
// Extract members from source team
const members: TeamMember[] = [];
if (team.members?.teamMembers) {
for (const teamMember of team.members.teamMembers) {
for (const membership of teamMember.memberships) {
members.push({
accountId: teamMember.accountId,
roleName: membership.role.name,
startDate: membership.startDate,
endDate: membership.endDate,
commitment: membership.commitment,
});
}
}
}
return { sourceId: team.id, targetId, name: team.name, members };
} catch (error) {
console.warn(`Failed to create team ${team.name}:`, error);
return { sourceId: team.id, targetId: undefined, name: team.name, members: [] };
}
});
// Execute creation tasks with concurrency limit
const results = await throttleAll(concurrency, createTasks);
// Filter out teams that failed to create (targetId is undefined)
const migratedTeams = results.filter((team): team is MigratedTeam => team.targetId !== undefined);
console.log(`Processed ${results.length} teams, successfully created ${migratedTeams.length}`);
return migratedTeams;
}
/**
* Migrates team links from source Tempo instance to target instance.
* For each migrated team, finds team links, maps project IDs from source to target via Jira project keys.
*/
async function migrateTeamLinks(concurrency: number, migratedTeams: MigratedTeam[]): Promise<void> {
console.log('Starting team links migration...');
let totalLinksProcessed = 0;
let successCount = 0;
// Create tasks for concurrent execution
const linkTasks = migratedTeams.map((team) => async () => {
try {
// Get team links from source instance
const sourceLinks = await getTeamLinks(TempoSource, team.sourceId);
if (sourceLinks.length === 0) {
return;
}
// Process each link
for (const link of sourceLinks) {
// Only process PROJECT type links
if (link.scope.type !== 'PROJECT') {
console.log(`Skipping non-PROJECT link for team ${team.name}: ${link.scope.type}`);
continue;
}
totalLinksProcessed++;
try {
// Get project from Jira source by ID
const sourceProject = await JiraSource.Project.getProject({
projectIdOrKey: String(link.scope.id),
});
if (!sourceProject.key) {
console.warn(
`Project ${link.scope.id} in source Jira has no key, skipping link for team ${team.name}`,
);
continue;
}
// Find project in Jira target by key
const targetProject = await JiraTarget.Project.getProject({
projectIdOrKey: sourceProject.key,
});
if (!targetProject.id) {
console.warn(
`Project ${sourceProject.key} in target Jira has no ID, skipping link for team ${team.name}`,
);
continue;
}
// Create team link in target with target project ID
await createTeamLink(team.targetId, Number.parseInt(targetProject.id, 10));
successCount++;
} catch (error) {
console.warn(`Failed to migrate link for team ${team.name}, project ID ${link.scope.id}:`, error);
}
}
} catch (error) {
console.warn(`Failed to get team links for team ${team.name} (source ID: ${team.sourceId}):`, error);
}
});
// Execute link migration tasks with concurrency limit
await throttleAll(concurrency, linkTasks);
if (totalLinksProcessed > 0) {
console.log(`Processed ${totalLinksProcessed} team links, successfully created ${successCount}`);
} else {
console.log(`Processed team links for ${migratedTeams.length} teams`);
}
}
/**
* Migrates team permission roles from source Tempo instance to target instance.
* Only migrates for teams that were successfully migrated.
* Updates non-editable roles if they exist in target (matched by name), creates new editable roles.
*/
async function migrateTeamPermissionRoles(concurrency: number, migratedTeams: MigratedTeam[]): Promise<void> {
console.log('Starting team permission roles migration...');
// Create a map of source team ID to target team ID
const sourceToTargetTeamMap = new Map(migratedTeams.map((team) => [team.sourceId, team.targetId]));
let totalRolesProcessed = 0;
let successCount = 0;
// Create tasks for concurrent execution
const permissionRoleTasks = migratedTeams.map((team) => async () => {
try {
// Get permission roles from source instance
const sourcePermissionRoles = await getPermissionRoles(TempoSource, team.sourceId);
if (sourcePermissionRoles.length === 0) {
return;
}
// Get permission roles from target instance
const targetPermissionRoles = await getPermissionRoles(TempoTarget, team.targetId);
const targetPermissionRoleMap = new Map(targetPermissionRoles.map((role) => [role.name, role]));
// Process each permission role
for (const sourceRole of sourcePermissionRoles) {
totalRolesProcessed++;
try {
// Map accessEntityIds (team IDs) from source to target
const targetAccessEntityIds: number[] = [];
if (sourceRole.accessEntities && sourceRole.accessEntities.length > 0) {
for (const entity of sourceRole.accessEntities) {
const targetTeamId = sourceToTargetTeamMap.get(entity.id);
if (targetTeamId !== undefined) {
targetAccessEntityIds.push(targetTeamId);
} else {
console.warn(
`Team ID ${entity.id} in permission role ${sourceRole.name} for team ${team.name} not found in migrated teams, skipping entity`,
);
}
}
}
// Extract permission keys from permissions array
const permissionKeys = sourceRole.permissions.map((permission) => permission.key);
// Extract permitted account IDs from permittedUsers array
const permittedAccountIds = sourceRole.permittedUsers.map((user) => user.accountId);
if (!sourceRole.editable) {
// Non-editable role: find in target by name and update
const targetRole = targetPermissionRoleMap.get(sourceRole.name);
if (targetRole) {
await updatePermissionRole(
targetRole.id,
sourceRole.name,
sourceRole.accessType,
targetAccessEntityIds.length > 0 ? targetAccessEntityIds : undefined,
permissionKeys.length > 0 ? permissionKeys : undefined,
permittedAccountIds.length > 0 ? permittedAccountIds : undefined,
);
successCount++;
} else {
console.warn(
`Non-editable permission role ${sourceRole.name} for team ${team.name} not found in target, skipping`,
);
}
} else {
// Editable role: create new
await createPermissionRole(
sourceRole.name,
sourceRole.accessType,
targetAccessEntityIds.length > 0 ? targetAccessEntityIds : undefined,
permissionKeys.length > 0 ? permissionKeys : undefined,
permittedAccountIds.length > 0 ? permittedAccountIds : undefined,
);
successCount++;
}
} catch (error) {
console.warn(`Failed to migrate permission role ${sourceRole.name} for team ${team.name}:`, error);
}
}
} catch (error) {
console.warn(`Failed to get permission roles for team ${team.name} (source ID: ${team.sourceId}):`, error);
}
});
// Execute permission role migration tasks with concurrency limit
await throttleAll(concurrency, permissionRoleTasks);
if (totalRolesProcessed > 0) {
console.log(`Processed ${totalRolesProcessed} permission roles, successfully migrated ${successCount}`);
} else {
console.log(`Processed permission roles for ${migratedTeams.length} teams`);
}
}
/**
* Migrates team members from source Tempo instance to target instance.
* Flattens members list and creates memberships with concurrency control.
* Maps roles by name and adjusts date ranges as needed.
* Returns a list of successfully migrated member account IDs.
*/
async function migrateTeamMembers(
concurrency: number,
migratedTeams: MigratedTeam[],
useInclusiveEndDate: boolean,
): Promise<string[]> {
console.log('Starting team members migration...');
// Get all roles from target instance to map role names to IDs
const targetRoles = await getRoles(TempoTarget);
const targetRoleMap = new Map(targetRoles.map((role) => [role.name, role.id]));
// Flatten all members from all migrated teams
const allMembers: Array<{
teamId: number;
teamName: string;
member: TeamMember;
}> = [];
for (const team of migratedTeams) {
for (const member of team.members) {
allMembers.push({
teamId: team.targetId,
teamName: team.name,
member,
});
}
}
if (allMembers.length === 0) {
console.log('No team members to migrate');
return [];
}
console.log(`Found ${allMembers.length} memberships to migrate across ${migratedTeams.length} teams`);
// Create tasks for concurrent execution
const membershipTasks = allMembers.map(({ teamId, teamName, member }) => async () => {
try {
// Find target role ID by role name
const targetRoleId = targetRoleMap.get(member.roleName);
if (!targetRoleId) {
const userInfo = await getUserDisplayInfo(member.accountId);
console.warn(
`Role ${member.roleName} not found in target instance, skipping membership for team ${teamName}, user ${userInfo}`,
);
return null;
}
// Convert commitment from 0-1 to percentage (0-100)
const commitmentPercent = Math.round(member.commitment * 100);
// Adjust date ranges if needed (currently keeping as-is, but can be adjusted here)
const adjustedStartDate = member.startDate; // TODO: Adjust date range if needed
const adjustedEndDate = member.endDate; // TODO: Adjust date range if needed
await createMembership(
teamId,
member.accountId,
targetRoleId,
commitmentPercent,
adjustedStartDate ?? undefined,
adjustedEndDate ?? undefined,
useInclusiveEndDate,
);
return member.accountId;
} catch (error) {
const userInfo = await getUserDisplayInfo(member.accountId);
console.warn(`Failed to create membership for team ${teamName}, user ${userInfo}:`, error);
return null;
}
});
// Execute membership creation tasks with concurrency limit
const results = await throttleAll(concurrency, membershipTasks);
// Filter out failed migrations and get unique accountIds
const successfullyMigratedMembers = results
.filter((accountId): accountId is string => accountId !== null)
.filter((accountId, index, self) => self.indexOf(accountId) === index);
console.log(
`Processed ${allMembers.length} memberships for ${migratedTeams.length} teams, successfully migrated ${successfullyMigratedMembers.length} unique members`,
);
return successfullyMigratedMembers;
}
/**
* Migrates user skill assignments from source Tempo instance to target instance.
* Only migrates for team members that were successfully migrated.
* Maps skills by name to target skill IDs.
*/
async function migrateUserSkillAssignments(concurrency: number, migratedMembers: string[]): Promise<void> {
console.log('Starting user skill assignments migration...');
// Get all skills from target instance to map skill names to IDs
const targetSkills = await getSkills(TempoTarget);
const targetSkillMap = new Map(targetSkills.map((skill) => [skill.name, skill.id]));
if (migratedMembers.length === 0) {
console.log('No team members to migrate skill assignments for');
return;
}
console.log(`Migrating skill assignments for ${migratedMembers.length} users`);
let totalUsersProcessed = 0;
let successCount = 0;
// Create tasks for concurrent execution
const skillAssignmentTasks = migratedMembers.map((accountId) => async () => {
try {
// Get skill assignments from source instance
const sourceSkills = await getSkillAssignments(TempoSource, accountId);
if (!sourceSkills || sourceSkills.length === 0) {
console.log(`No skill assignments found for user ${accountId}`);
return;
}
totalUsersProcessed++;
// Map skill names to target skill IDs
const targetSkillIds: number[] = [];
const skippedSkills: string[] = [];
for (const skill of sourceSkills) {
const targetSkillId = targetSkillMap.get(skill.name);
if (targetSkillId) {
targetSkillIds.push(targetSkillId);
} else {
skippedSkills.push(skill.name);
}
}
if (skippedSkills.length > 0) {
console.warn(
`Skipping ${skippedSkills.length} skills not found in target for user ${accountId}: ${skippedSkills.join(', ')}`,
);
}
if (targetSkillIds.length === 0) {
console.log(`No matching skills found in target for user ${accountId}`);
return;
}
// Create skill assignment in target
await createSkillAssignment(accountId, targetSkillIds);
successCount++;
} catch (error) {
const userInfo = await getUserDisplayInfo(accountId);
console.warn(`Failed to migrate skill assignments for user ${userInfo}:`, error);
}
});
// Execute skill assignment tasks with concurrency limit
await throttleAll(concurrency, skillAssignmentTasks);
if (totalUsersProcessed > 0) {
console.log(`Processed ${totalUsersProcessed} user skill assignments, successfully migrated ${successCount}`);
} else {
console.log(`Processed skill assignments for ${migratedMembers.length} users`);
}
}
/**
* Gets user display name and email from Jira source instance, with caching.
* Returns formatted string "Display Name (email@example.com)" or accountId if lookup fails.
*/
async function getUserDisplayInfo(accountId: string): Promise<string> {
if (USER_CACHE.has(accountId)) {
return USER_CACHE.get(accountId)!;
}
try {
const user = await JiraSource.User.getUser({
accountId,
});
let displayInfo: string;
if (user.displayName && user.emailAddress) {
displayInfo = `${user.displayName} (${user.emailAddress})`;
} else if (user.displayName) {
displayInfo = `${user.displayName} (${accountId})`;
} else if (user.emailAddress) {
displayInfo = user.emailAddress;
} else {
displayInfo = accountId;
}
USER_CACHE.set(accountId, displayInfo);
return displayInfo;
} catch {
// If lookup fails, cache the accountId itself to avoid repeated failed lookups
USER_CACHE.set(accountId, accountId);
return accountId;
}
}
interface TeamMember {
accountId: string;
roleName: string;
startDate: string | null;
endDate: string | null;
commitment: number; // 0 - 1
}
interface MigratedTeam {
sourceId: number;
targetId: number;
name: string;
members: TeamMember[];
}
import { TempoCloudApi } from '@managed-api/tempo-cloud-v4-sr-connect';
import { FetchOptions, Response } from '@managed-api/commons-core';
import TempoTarget from './api/tempo/cloud/target';
export async function getTeams(apiConnection: TempoCloudApi, limit = 5000) {
let offset = 0;
const teams: Team[] = [];
while (true) {
const response = await refetch(
apiConnection,
`/4/teams?limit=${limit}&offset=${offset}&includeMemberships=true`,
);
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const data = await response.json<GetTeamsResponse>();
teams.push(...data.results);
if (data.results.length < limit) {
break;
}
offset += limit;
}
return teams;
}
export async function getSkills(apiConnection: TempoCloudApi, limit = 5000) {
let offset = 0;
const skills: Skill[] = [];
while (true) {
const response = await refetch(apiConnection, `/4/skills?limit=${limit}&offset=${offset}`);
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const data = await response.json<GetSkillsResponse>();
skills.push(...data.results);
if (data.results.length < limit) {
break;
}
offset += limit;
}
return skills;
}
export async function getRoles(apiConnection: TempoCloudApi) {
const response = await refetch(apiConnection, '/4/roles');
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const roles = await response.json<GetRolesResponse>();
return roles.results;
}
export async function getPrograms(apiConnection: TempoCloudApi) {
const response = await refetch(apiConnection, '/4/programs');
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const programs = await response.json<GetProgramsResponse>();
return programs.results;
}
export async function getTeamLinks(apiConnection: TempoCloudApi, teamId: number, limit = 5000) {
let offset = 0;
const teamLinks: TeamLink[] = [];
while (true) {
const response = await refetch(apiConnection, `/4/teams/${teamId}/links?limit=${limit}&offset=${offset}`);
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const data = await response.json<GetTeamLinksResponse>();
teamLinks.push(...data.results);
if (data.results.length < limit) {
break;
}
offset += limit;
}
return teamLinks;
}
export async function getPermissionRoles(
apiConnection: TempoCloudApi,
teamId: number,
limit = 5000,
): Promise<PermissionRole[]> {
let offset = 0;
const permissionRoles: PermissionRole[] = [];
while (true) {
const response = await refetch(
apiConnection,
`/4/permission-roles?teamId=${teamId}&limit=${limit}&offset=${offset}`,
);
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const data = await response.json<GetPermissionRolesResponse>();
permissionRoles.push(...data.results);
if (data.results.length < limit) {
break;
}
offset += limit;
}
return permissionRoles;
}
export async function getSkillAssignments(apiConnection: TempoCloudApi, accountId: string): Promise<Skill[]> {
const response = await refetch(apiConnection, `/4/skill-assignments/${accountId}/USER`);
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
return await response.json<Skill[]>();
}
export async function createPermissionRole(
name: string,
accessType: 'TEAM' | 'GLOBAL',
accessEntityIds?: number[],
permissionKeys?: string[],
permittedAccountIds?: string[],
) {
const response = await refetch(TempoTarget, '/4/permission-roles', {
method: 'POST',
body: JSON.stringify({
name,
accessType,
accessEntityIds,
permissionKeys,
permittedAccountIds,
}),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function updatePermissionRole(
id: number,
name: string,
accessType: 'TEAM' | 'GLOBAL',
accessEntityIds?: number[],
permissionKeys?: string[],
permittedAccountIds?: string[],
) {
const response = await refetch(TempoTarget, `/4/permission-roles/${id}`, {
method: 'PUT',
body: JSON.stringify({
name,
accessType,
accessEntityIds,
permissionKeys,
permittedAccountIds,
}),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createSkill(name: string) {
const response = await refetch(TempoTarget, '/4/skills', {
method: 'POST',
body: JSON.stringify({ name }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createRole(name: string) {
const response = await refetch(TempoTarget, '/4/roles', {
method: 'POST',
body: JSON.stringify({ name }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createProgram(name: string, managerAccountId?: string) {
const response = await refetch(TempoTarget, '/4/programs', {
method: 'POST',
body: JSON.stringify({ name, managerAccountId }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createTeamLink(teamId: number, projectId: number) {
const response = await refetch(TempoTarget, `/4/team-links`, {
method: 'POST',
body: JSON.stringify({ teamId, scopeId: projectId, scopeType: 'PROJECT' }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createMembership(
teamId: number,
accountId: string,
roleId?: number,
commitmentPercent?: number, // 0 - 100
from?: string, // YYYY-MM-DD
to?: string, // YYYY-MM-DD,
useInclusiveEndDate = true,
) {
const response = await refetch(TempoTarget, '/4/team-memberships', {
method: 'POST',
body: JSON.stringify({ teamId, accountId, roleId, commitmentPercent, from, to, useInclusiveEndDate }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
export async function createTeam(
name: string,
administrative: boolean,
isPublic: boolean,
summary?: string,
leadAccountId?: string,
programId?: number,
): Promise<number | undefined> {
const response = await refetch(TempoTarget, '/4/teams', {
method: 'POST',
body: JSON.stringify({ name, administrative, public: isPublic, summary, leadAccountId, programId }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
const createdTeam = await response.json<CreateTeamResponse>();
return createdTeam.id;
}
export async function createSkillAssignment(assigneeId: string, skillIds: number[]) {
const response = await refetch(TempoTarget, '/4/skill-assignments', {
method: 'POST',
body: JSON.stringify({ assigneeId, skillIds, assigneeType: 'USER' }),
});
if (!response.ok) {
const errorMessage = await extractErrorMessage(response);
throw new Error(`${response.status} - ${errorMessage}`);
}
}
/**
* Extracts error message from Tempo API response text.
* Tries to parse JSON and extract message from errors array, falls back to raw text.
*/
async function extractErrorMessage(response: Response): Promise<string> {
const text = await response.text();
try {
const json = JSON.parse(text) as { errors?: Array<{ message?: string }> };
if (json.errors && json.errors.length > 0 && json.errors[0].message) {
return json.errors[0].message;
}
} catch {
// If JSON parsing fails, return the raw text
}
return text;
}
// Retry attempts for 500 errors (0 = no retries)
let tempoFailureRetryAttempts = 0;
/**
* Sets the number of retry attempts for 500 errors from Tempo API.
* Should be called once at the start of migration with value from context.environment.vars.TEMPO_FAILURE_RETRY_ATTEMPTS.
*/
export function setTempoFailureRetryAttempts(attempts: number): void {
tempoFailureRetryAttempts = attempts;
}
/**
* Custom fetch function that re-tries when being throttled (429) or when server errors occur (500)
*/
async function refetch(
apiConnection: TempoCloudApi,
url: string,
options?: FetchOptions,
retryCount = 0,
): Promise<Response> {
while (true) {
const response = await apiConnection.fetch(url, options);
if (response.status === 429) {
if (response.headers.get('x-stitch-rate-limit')) {
console.warn(
'Rate limited by ScriptRunner Connect, waiting 1 second before retrying. Please consider upgrading your plan to get higher rate limits.',
);
} else {
console.warn(
'Rate limited by Tempo, waiting 1 second before retrying. Please consider reducing CONCURRENCY setting to reduce load on the API.',
);
}
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second before retrying
return await refetch(apiConnection, url, options, retryCount);
} else if (response.status === 500 && retryCount < tempoFailureRetryAttempts) {
const attemptNumber = retryCount + 1;
console.warn(
`Tempo API returned 500 error, retrying (attempt ${attemptNumber}/${tempoFailureRetryAttempts})...`,
);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second before retrying
return await refetch(apiConnection, url, options, retryCount + 1);
} else {
return response;
}
}
}
interface GetTeamsResponse {
metadata: {
count: number;
};
results: Team[];
}
interface Team {
id: number;
name: string;
summary: string;
lead: {
accountId: string;
} | null;
program: {
id: string;
name: string;
} | null;
administrative: boolean;
members: {
teamMembers: {
accountId: string;
memberships: {
id: number;
role: {
id: number;
name: string;
};
startDate: string | null; // YYYY-MM-DD
endDate: string | null; // YYYY-MM-DD
commitment: number; // 0 - 1
}[];
}[];
};
}
interface CreateTeamResponse {
id: number;
}
interface GetSkillsResponse {
metadata: {
count: number;
};
results: Skill[];
}
interface Skill {
id: number;
name: string;
}
interface GetRolesResponse {
results: Role[];
}
interface Role {
id: number;
name: string;
default: boolean;
}
interface GetProgramsResponse {
results: Program[];
}
interface Program {
id: number;
name: string;
manager?: {
accountId: string;
};
}
interface GetTeamLinksResponse {
metadata: {
count: number;
};
results: TeamLink[];
}
interface TeamLink {
id: number;
scope: {
id: number;
type: 'PROJECT' | 'BOARD';
};
}
interface GetPermissionRolesResponse {
metadata: {
count: number;
};
results: PermissionRole[];
}
interface PermissionRole {
id: number;
name: string;
permissions: {
key: string;
}[];
permittedUsers: {
accountId: string;
}[];
accessType: 'TEAM' | 'GLOBAL';
accessEntities: {
id: number;
}[];
editable: boolean;
}
© 2025 ScriptRunner · Terms and Conditions · Privacy Policy · Legal Notice · Cookie Preferences