Migrate Tempo Cloud teams from one instance to another


Intro video is not displayed because you have disallowed functional cookies.
Get Started

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

About the template


Migrates Tempo Cloud entities from one instance to another. Supports migrating skills, roles, programs, teams, team permission roles, team-project links, team members, and user skill assignments. 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

TypeScriptRunMigration

README


Tempo Teams Migration

📋 Overview

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:

  • Compares entities between source and target instances
  • Only creates entities that don't already exist in the target (based on name matching)
  • Handles concurrent operations with configurable concurrency limits
  • Automatically retries on rate limits and Tempo server errors
  • Migrates team permission roles, updating non-editable roles or creating new editable ones
  • Maps team-project links by finding matching Jira projects via project keys
  • Preserves team member assignments with role mappings and date ranges
  • Migrates user skill assignments for successfully migrated team members

Business Value:

  • Streamlines migration of Tempo configuration and team structures between instances
  • Reduces manual data entry and potential errors
  • Maintains data relationships (teams to programs, members to teams, skills to users)
  • Provides flexible, step-by-step migration control

🖊️ Setup

Connectors

Create the following Connectors in ScriptRunner Connect web UI:

  • Tempo Cloud (Source): Connector for the source Tempo Cloud instance
  • Tempo Cloud (Target): Connector for the target Tempo Cloud instance
  • Jira Cloud (Source): Connector for the source Jira Cloud instance (required for team-project link migration)
  • Jira Cloud (Target): Connector for the target Jira Cloud instance (required for team-project link migration)

For detailed Connector setup instructions, see the Connectors documentation.

API Connections

Create the following API Connections in your workspace:

  • Tempo Cloud Source: Link to the Tempo Cloud (Source) Connector
  • Tempo Cloud Target: Link to the Tempo Cloud (Target) Connector
  • Jira Cloud Source: Link to the Jira Cloud (Source) Connector
  • Jira Cloud Target: Link to the Jira Cloud (Target) Connector

For detailed API Connection setup instructions, see the API Connections documentation.

Parameters

Configure the following Parameters in the ScriptRunner Connect web UI:

Parameter NameTypeDescriptionExample Value
MIGRATE_SKILLSBooleanWhether to migrate skills from source to target.true
MIGRATE_ROLESBooleanWhether to migrate roles from source to target.true
MIGRATE_PROGRAMSBooleanWhether to migrate programs from source to target.true
MIGRATE_TEAMSBooleanWhether to migrate teams from source to target.true
MIGRATE_TEAM_PERMISSION_ROLESBooleanWhether to migrate team permission roles. Requires MIGRATE_TEAMS to be true.true
MIGRATE_TEAM_LINKSBooleanWhether to migrate team-project links. Requires MIGRATE_TEAMS to be true.true
MIGRATE_TEAM_MEMBERSBooleanWhether to migrate team members. Requires MIGRATE_TEAMS to be true.true
MIGRATE_USER_SKILL_ASSIGNMENTSBooleanWhether to migrate user skill assignments. Requires MIGRATE_TEAM_MEMBERS to be true.true
PUBLIC_TEAMSBooleanWhether migrated teams should be created as public teams.false
TEAM_MEMBER_INCLUSIVE_END_DATEBooleanWhether to use inclusive end dates when migrating team memberships.true
CONCURRENCYNumberHow many concurrent migration jobs to maintain. Increase for higher throughput, decrease if hitting rate limits.5
TEMPO_FAILURE_RETRY_ATTEMPTSNumberHow many times to retry when Tempo API returns 500 errors before giving up. Set to 0 to disable retries.3

Migration Order Dependencies:

  • Skills and roles should be migrated before teams (teams may reference roles)
  • Programs should be migrated before teams (teams may belong to programs)
  • Teams must be migrated before team permission roles, team links, and team members
  • Team members must be migrated before user skill assignments

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:

  • You know that the entity type is not used in your Tempo instance (to save time on checking)
  • Running a very large batch of items and you want to run the migration in sequence by entity type

🚀 Using the Integration

Running the Migration

  1. Configure Parameters: Set all required Parameters in the ScriptRunner Connect web UI with your desired migration settings
  2. Trigger the Script: Click the play button next to RunMigration in the Resource Manager tree or code editor header
  3. Monitor Progress: Check the Script Invocation Logs in the web UI to view migration progress and any warnings or errors
  4. Re-run if Needed: If the migration doesn't complete within the 15-minute execution limit, simply run the script again. It will continue migrating remaining entities that don't already exist in the target

Migration Process

The migration follows this sequence:

  1. Skills Migration (if enabled): Creates skills that don't exist in target
  2. Roles Migration (if enabled): Creates roles that don't exist in target
  3. Programs Migration (if enabled): Creates programs that don't exist in target
  4. Teams Migration (if enabled): Creates teams that don't exist in target, preserving team structure and memberships
  5. Team Permission Roles Migration (if enabled and teams were migrated): Migrates permission roles for migrated teams, updating non-editable roles or creating new editable ones
  6. Team Links Migration (if enabled and teams were migrated): Links migrated teams to Jira projects by matching project keys
  7. Team Members Migration (if enabled and teams were migrated): Assigns team members to migrated teams, mapping roles and preserving date ranges
  8. User Skill Assignments Migration (if enabled and team members were migrated): Assigns skills to users who were successfully migrated as team members

Verifying Migration

After migration completes:

  1. Check Logs: Review Script Invocation Logs for:
    • Counts of entities processed
    • Entities skipped (already exist in target)
    • Any warnings or errors
  2. Verify in Tempo: Log into the target Tempo instance and verify:
    • Skills, roles, and programs are present
    • Teams are created with correct settings
    • Team permission roles are migrated with correct permissions and user assignments
    • Team-project links are established
    • Team members are assigned with correct roles and date ranges
    • User skill assignments are present

Error Handling

The migration includes robust error handling:

  • Rate Limiting: Automatically retries on 429 (rate limit) errors with 1-second delays
  • Server Errors: Retries on 500 errors up to the configured number of attempts (TEMPO_FAILURE_RETRY_ATTEMPTS)
  • Individual Failures: Failed entity creations are logged as warnings but don't stop the migration
  • User Lookups: When errors involve account IDs, user display names and emails are shown for easier identification

❗️ Considerations

Rate Limiting

  • Tempo Cloud API has rate limits that vary by plan
  • If you encounter rate limiting, reduce the CONCURRENCY parameter
  • The script automatically retries on rate limits, but lower concurrency reduces the frequency

Data Matching

  • Name-based Matching: Entities are matched by name, not ID
  • Case Sensitivity: Matching is case-sensitive
  • Existing Entities: Entities with matching names in the target are skipped (not updated)

Team-Project Links

  • Only PROJECT type links are migrated (board links are skipped)
  • Project matching is done by Jira project key, not ID
  • Both source and target Jira instances must have projects with matching keys
  • If a project key doesn't exist in the target, the link is skipped with a warning

Team Permission Roles

  • Only migrated for teams that were successfully migrated
  • Non-editable roles: Updated in target instance if found by name (matched by name)
  • Editable roles: Created as new roles in target instance
  • Permission keys and permitted user account IDs are preserved
  • If a non-editable role doesn't exist in target, it's skipped with a warning
  • Team IDs referenced in permission roles must exist in the migrated teams list

Team Members

  • Role names must match between source and target instances
  • If a role doesn't exist in the target, the membership is skipped with a warning
  • Date ranges are preserved as-is from the source
  • Commitment percentages are converted from 0-1 scale to 0-100 scale

User Skill Assignments

  • Only migrated for users who were successfully added as team members
  • Skills are matched by name between source and target
  • If a skill doesn't exist in the target, it's skipped with a warning
  • Users without any matching skills will not have skill assignments created

Execution Time

  • Large migrations may take significant time depending on:
    • Number of entities to migrate
    • Concurrency setting
    • API response times
    • Rate limiting delays
  • Script execution has a 15-minute maximum limit
  • For very large migrations that might not complete in 15 minutes, you can run the migration script again. The script will pick up items that weren't migrated before, as it only creates entities that don't already exist in the target (based on name matching)
  • Alternatively, for very large batches, consider running in stages by enabling one entity type at a time

🔧 Troubleshooting

Common Warnings and Their Causes

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

  • Cause: The team lead account ID from the source instance doesn't exist in the target instance
  • Solution: Verify the lead user exists in the target Jira instance, or remove the lead assignment from the source team before migration

Failed to migrate link for team [Team Name], project ID [ID]: NotFoundError: No project could be found with key '[KEY]'

  • Cause: The Jira project with the specified key doesn't exist in the target instance
  • Solution: Ensure the project exists in the target Jira instance with the same project key, or the link will be skipped

Failed to create membership for team [Team Name], user [User Name] ([Account ID]): Error: 404 - User is invalid

  • Cause: The user account ID doesn't exist in the target Jira instance
  • Solution: Verify the user exists in the target Jira instance, or they won't be added to the team

Failed to create program [Program Name]: Error: 400 - Manager must be a Jira user

  • Cause: The program manager account ID from the source instance doesn't exist in the target instance
  • Solution: Verify the manager user exists in the target Jira instance, or remove the manager assignment from the source program before migration

Failed to migrate skill assignments for user [User Name] ([Email]): Error: 400 - One or more skills from input are already assigned to this resource

  • Cause: Skills weren't migrated to the target instance, but skill assignment migration was attempted. The error indicates trying to assign skills that are already assigned, or skills that don't exist in the target
  • Solution: Ensure skills are migrated first by setting 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 migration

Common Issues

Rate Limiting Errors:

  • Symptom: Frequent rate limit warnings in logs
  • Solution: Reduce CONCURRENCY parameter value
  • Check: ScriptRunner Connect plan limits in web UI

Tempo 500 Server Errors:

  • Symptom: Server error messages in logs
  • Solution: Increase TEMPO_FAILURE_RETRY_ATTEMPTS to allow more retries
  • Check: Tempo Cloud service status

Team Links Not Migrating:

  • Symptom: Teams migrated but links missing
  • Solution: Verify project keys exist in both source and target Jira instances
  • Check: Ensure MIGRATE_TEAM_LINKS is true and teams were successfully migrated

Team Members Not Migrating:

  • Symptom: Teams created but no members assigned
  • Solution: Verify role names match between source and target instances
  • Check: Ensure MIGRATE_TEAM_MEMBERS is true and teams were successfully migrated

User Skill Assignments Not Migrating:

  • Symptom: Team members migrated but no skill assignments
  • Solution: Verify skill names match between source and target instances
  • Check: Ensure MIGRATE_USER_SKILL_ASSIGNMENTS is true and team members were successfully migrated

Team Permission Roles Not Migrating:

  • Symptom: Teams migrated but permission roles missing
  • Solution: Verify teams were successfully migrated and permission roles exist in source
  • Check: Ensure MIGRATE_TEAM_PERMISSION_ROLES is true and teams were successfully migrated
  • Note: Non-editable roles must exist in target (matched by name) to be updated

Debugging Steps

  1. Check Script Invocation Logs: Review console output for detailed migration progress
  2. Review HTTP Logs: Inspect API requests and responses for errors
  3. Verify API Connections: Ensure all API Connections are properly configured and authenticated
  4. Check Parameters: Verify all Parameters are set correctly
  5. Test Incrementally: Enable one migration type at a time to isolate issues

Getting Help

  • Logs: Check Script Invocation Logs and HTTP Logs in ScriptRunner Connect web UI
  • Documentation: Refer to Tempo Cloud API documentation for API details

API Connections


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

import { 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[];
}
TypeScriptTempo

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