Migrate Tempo Cloud plans 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


This template migrates Tempo Cloud non-flex plans from one instance to another. Get started to learn more.

About ScriptRunner Connect


What is ScriptRunner Connect?

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

Can I try it out for free?

Yes. ScriptRunner Connect comes with a forever free tier.

Can I customize the integration logic?

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

Can I change the integration to communicate with additional apps?

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

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

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

Do I have to host it myself?

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

What about security?

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

Template Content


README

Scripts

TypeScriptDeletePlansFromTarget
TypeScriptGetMigrationReport
Sync HTTP Event

README


Tempo Cloud Plans Migration

📋 Overview

This integration migrates Tempo Cloud plans from a source instance to a target instance.

This template was developed with the assistance of an AI agent.

⚠️ Important: We strongly recommend upgrading to a paid ScriptRunner Connect plan before running large migrations. The rate limits in the free plan are too restrictive for meaningful progress, though the free plan can be used for testing purposes.

⚠️ Important: This template does not migrate flex plans. Only regular plans are migrated.

Note: All times displayed in reports and logs are in UTC timezone.

Key Features:

  • Long-running migrations: Automatically handles migrations that take longer than 15 minutes by checkpointing and resuming
  • Batch processing: Processes plans in configurable batches with concurrency control
  • Failure tracking: Tracks failed plans with detailed error information for retry
  • Progress reporting: Real-time HTML report showing migration progress, statistics, and failures
  • Retry mechanism: Retry previously failed plans without re-migrating successful ones
  • Graceful stopping: Stop migration mid-process and resume later
  • Rate limit handling: Automatically handles API rate limiting with intelligent retry logic

How it works:

  1. Fetches plans from the source Tempo Cloud instance within a specified date range
  2. Resolves issue and project IDs from source to target using Jira Cloud APIs
  3. Creates corresponding plans in the target Tempo Cloud instance
  4. Tracks success/failure for each plan
  5. Automatically resumes from checkpoints if migration exceeds time limits
  6. Provides real-time progress reporting via web interface

🖊️ Setup

Connectors

Configure the following Connectors in ScriptRunner Connect:

  • 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 (used to resolve issue/project IDs)
  • Jira Cloud (Target): Connector for the target Jira Cloud instance (used to resolve issue/project IDs)

For detailed connector setup instructions, refer to the ScriptRunner Connect web UI.

API Connections

Configure the following API Connections in your workspace:

  • Tempo Cloud Source: Link to the Tempo Cloud (Source) Connector
    • Import path: ./api/tempo/cloud/source
  • Tempo Cloud Target: Link to the Tempo Cloud (Target) Connector
    • Import path: ./api/tempo/cloud/target
  • Jira Cloud Source: Link to the Jira Cloud (Source) Connector
    • Import path: ./api/jira/cloud/source
  • Jira Cloud Target: Link to the Jira Cloud (Target) Connector
    • Import path: ./api/jira/cloud/target

Event Listeners

Configure one Generic Event Listener:

  • GetMigrationReport: Sync HTTP Event Listener
    • Script: GetMigrationReport
    • Purpose: Provides real-time HTML report of migration progress
    • Access the report by navigating to the configured URL path

Parameters

Configure the following Parameters in the ScriptRunner Connect web UI:

Required Parameters

  • FROM_DATE (Date): Start date for filtering plans to migrate (inclusive, UTC timezone)
  • TO_DATE (Date): End date for filtering plans to migrate (inclusive, UTC timezone)
  • PAGE_SIZE (Number): Number of plans to process per batch (recommended: 50-100, maximum recommended: 500)
    • ⚠️ Important: With larger PAGE_SIZE values, the delay between requesting a migration stop and when it actually stops increases, as more items need to be processed in the current batch. Best not to exceed 500.
  • API_CONCURRENCY (Number): Maximum concurrent Tempo API calls (recommended: 5-10, adjust based on rate limits)
    • ⚠️ Warning: High concurrency settings can also trigger 5xx errors from Tempo's side. If you encounter 5xx errors, try reducing this value.
  • RETRY_FAILURES (Boolean): When enabled, retries previously failed plans instead of migrating new ones
  • HALT_WHEN_PLAN_MIGRATION_FAILS (Boolean): When enabled, stops migration immediately on first failure
  • VERBOSE (Boolean): Enable detailed logging for debugging

Reporting Parameters (Report folder)

  • MAX_DISPLAYED_BATCHES (Number): Maximum batches to show in report (-1 for all, 0 to hide section)
  • MAX_DISPLAYED_FAILURES (Number): Maximum failures to show in report (-1 for all, 0 to hide section)

Advanced Parameters (Advanced folder)

⚠️ Warning: Only modify these if you understand the implications.

  • BATCH_CYCLE_CUTOFF_TIME_MULTIPLIER (Number): Multiplier for average batch time when calculating if there's time for next batch
  • BATCH_CYCLE_MIN_TIME (Number): Minimum seconds to reserve before starting next batch
  • RETRY_CUTOFF_TIME (Number): Seconds before timeout to stop retrying rate-limited requests

Simulation Parameters (Simulation folder)

Development/testing only - Not needed for production migrations:

  • RESTART_SCRIPT_AFTER_FIRST_BATCH (Boolean): Force script restart after first batch (for testing)
  • PERCENTAGE_OF_SIMULATED_FAILURES (Number): Percentage (0-1) of plans to fail on purpose (for testing)

🚀 Using the Migration

Starting a Migration

  1. Configure Parameters: Set FROM_DATE, TO_DATE, PAGE_SIZE, and API_CONCURRENCY based on your migration needs
  2. Run Migration: Execute the MigratePlans script manually from the ScriptRunner Connect web UI
  3. Monitor Progress: Access the migration report via the Generic Event Listener URL to view real-time progress

Viewing Migration Progress

Navigate to the Generic Event Listener URL configured for GetMigrationReport to view:

  • Migration status (Running, Completed, or Stopped)
  • Total plans migrated and failed
  • Batch processing statistics
  • Detailed failure information
  • Throttle counts (rate limiting events)

⚠️ Important: Refresh the reporting page regularly to see the latest migration updates. The page does not auto-refresh.

Stopping a Migration

⚠️ CRITICAL: Do NOT stop the migration script directly from the ScriptRunner Connect web UI. This will cause the currently running batch to be processed again, leading to duplicate plans in the target instance.

Correct procedure:

  1. Access Report: Navigate to the migration report page
  2. Click Stop: Click the "Stop Migration" button on the reporting page
  3. Wait for Completion: The migration will complete the current batch and then stop gracefully
  4. Resume Later: Run MigratePlans script again to resume (it will automatically clear the stop request)

⚠️ Important: After stopping the migration, do not refresh the page using the browser's refresh button. Browsers may re-trigger the POST request, which could stop the migration again if it was restarted in the meantime. If you need to reload the page, manually navigate to the URL instead of using the refresh button (browsers usually warn you about this beforehand).

Retrying Failed Plans

⚠️ CRITICAL: When switching to retry mode, you MUST run the ResetCursor script BEFORE running MigratePlans script. This is important - no plans will be retried until the cursor position has been reset.

  1. Review Failures: Check the migration report to see which plans failed
  2. Enable Retry Mode: Set RETRY_FAILURES parameter to true
  3. Reset Cursor: Run ResetCursor script first - this resets the retry position to the beginning
  4. Run Migration: Execute MigratePlans script
  5. Monitor Progress: The migration will retry only failed plans, removing successful retries from the failure list

Resetting Retry Position Mid-Way: The ResetCursor script can also be used to reset the retry position mid-way through a retry cycle. For example, if you notice an issue and don't wish to wait until the end, you can:

  1. Stop the migration using the "Stop Migration" button on the reporting page
  2. Run the ResetCursor script to reset the cursor position
  3. Run MigratePlans again to start retrying from the beginning of the failed plans list

Resetting Migration State

To start a fresh migration:

  1. Run the ResetMigration script
  2. This clears all migration state and allows you to start from the beginning

Utility Scripts

  • MigratePlans: Main migration script
  • GetMigrationReport: Generates HTML progress report (accessed via Generic Event Listener)
  • ResetMigration: Clears migration state to start fresh
  • ResetCursor: Resets pagination cursor position
  • LogMigrationState: Logs current migration state to console
  • GenerateDummyPlans: Generates test plans in source instance (for testing)
  • DeletePlansFromTarget: Deletes plans from target instance (for cleanup/testing)

❗️ Considerations

Rate Limiting

  • The migration automatically handles rate limiting from both Tempo Cloud API and ScriptRunner Connect
  • Adjust API_CONCURRENCY if you encounter frequent rate limiting
  • Rate limit events are tracked and displayed in the migration report

Large Migrations

  • Migrations exceeding 15 minutes automatically checkpoint and resume
  • No manual intervention required - the script triggers itself to continue
  • Migration state is preserved between invocations
  • Migration Duration Limit: Migrations are allowed to run up to 50 hours (maximum 200 automatic resumptions). After this point, the migration needs to be restarted manually by running MigratePlans again

Failed Plans

  • Failed plans are tracked with detailed error messages and timestamps
  • Common failure reasons include:
    • Permission errors (user doesn't have permission to plan for specific assignee)
    • Missing issues or projects in target instance
    • Invalid plan data
  • Use RETRY_FAILURES mode to retry failed plans after resolving underlying issues

Data Consistency

  • Plans are migrated with all their properties (dates, assignees, effort, recurrence rules, etc.)
  • Issue and project IDs are automatically resolved from source to target using Jira Cloud APIs
  • The migration preserves plan relationships and metadata

Performance

  • Batch size (PAGE_SIZE) affects migration speed and memory usage
    • Larger batch sizes increase the delay before a stop request takes effect
    • Recommended maximum: 500 plans per batch
  • Higher concurrency (API_CONCURRENCY) speeds up migration but may trigger rate limits or 5xx errors from Tempo
  • Recommended starting values: PAGE_SIZE: 50-100, API_CONCURRENCY: 5-10
  • Adjust based on your instance's rate limits and performance characteristics

🔧 Troubleshooting

Migration Stuck or Not Progressing

  1. Check Script Invocation Logs: Review logs in ScriptRunner Connect web UI for errors
  2. Check Migration Report: View the report to see current status and any failures
  3. Verify Parameters: Ensure FROM_DATE and TO_DATE are set correctly
  4. Check Rate Limiting: Review throttle counts in the report - high counts indicate rate limiting issues

High Failure Rate

  1. Review Failure Reasons: Check the failures table in the migration report for specific error messages
  2. Common Issues:
    • Permission errors: Ensure users have planning permissions in target instance
    • Missing issues/projects: Verify all referenced issues and projects exist in target instance
    • Invalid dates: Check that plan dates are valid
  3. Enable Verbose Logging: Set VERBOSE: true for detailed error information

Rate Limiting Issues

  1. Reduce Concurrency: Lower API_CONCURRENCY parameter
  2. Increase Batch Size: Increase PAGE_SIZE to reduce number of API calls
  3. Check Throttle Counts: Monitor throttle counts in the migration report
  4. Wait and Retry: The migration automatically retries rate-limited requests

Migration Not Resuming

  1. Check Migration State: Run LogMigrationState script to view current state
  2. Verify Stop Request: If migration shows as "Stopped", run MigratePlans again to clear it
  3. Reset if Needed: Use ResetMigration to start fresh if state is corrupted

Report Not Accessible

  1. Verify Event Listener: Ensure Generic Event Listener is configured and active
  2. Check URL Path: Verify you're accessing the correct URL path configured for the Event Listener
  3. Check Permissions: Ensure you have access to the workspace

Plans Not Found

  1. Verify Date Range: Check that FROM_DATE and TO_DATE include the plans you want to migrate
  2. Check Source Instance: Verify plans exist in the source instance for the specified date range
  3. Review Filters: Ensure no additional filters are excluding the plans

API Connections


TypeScriptDeletePlansFromTarget

import TempoCloudTarget from './api/tempo/cloud/target';
import { throttleAll } from 'promise-throttle-all';
import type { PlanAsResponse } from '@managed-api/tempo-cloud-v4-core/definitions/PlanAsResponse';

/**
 * Main function to delete plans from target instance
 */
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const startTime = Date.now();

    // Format dates for API
    const fromDate = formatDateForApi(context.environment.vars.FROM_DATE);
    const toDate = formatDateForApi(context.environment.vars.TO_DATE);

    console.log('Starting plan deletion from target instance', {
        fromDate,
        toDate,
        apiConcurrency: context.environment.vars.API_CONCURRENCY,
    });

    try {
        // Step 1: Fetch all plans from target instance
        const plans = await fetchAllPlans(context, fromDate, toDate);

        if (plans.length === 0) {
            console.log('No plans found in the specified date range. Nothing to delete.');
            return;
        }

        // Step 2: Delete all plans concurrently
        const result = await deletePlans(context, plans);

        const duration = Date.now() - startTime;
        console.log('Plan deletion completed', {
            totalPlans: plans.length,
            deleted: result.deleted,
            failed: result.failed,
            duration: `${(duration / 1000).toFixed(2)}s`,
        });

        if (result.errors.length > 0) {
            console.error('Errors encountered during deletion:', result.errors);
        }
    } catch (error) {
        console.error('Error during plan deletion:', error);
        throw error;
    }
}

/**
 * Formats a Date object to ISO date string (YYYY-MM-DD) for Tempo API
 */
function formatDateForApi(date: Date): string {
    return date.toISOString().split('T')[0];
}

/**
 * Fetches all plans from target instance within the date range
 */
async function fetchAllPlans(context: Context<EV>, fromDate: string, toDate: string): Promise<PlanAsResponse[]> {
    const allPlans: PlanAsResponse[] = [];
    let offset = 0;
    const pageSize = context.environment.vars.PAGE_SIZE ?? 1000;

    console.log('Fetching plans from target instance...', {
        fromDate,
        toDate,
        pageSize,
    });

    while (true) {
        try {
            const plansResponse = await TempoCloudTarget.Plan.getPlans({
                from: fromDate,
                to: toDate,
                limit: pageSize,
                offset,
            });

            const plans = plansResponse.results ?? [];
            allPlans.push(...plans);

            const count = plansResponse.metadata?.count ?? 0;
            console.log(`Fetched ${plans.length} plans (offset: ${offset}, total so far: ${allPlans.length})`);

            // Check if there are more plans to fetch
            if (count < pageSize) {
                break;
            }

            offset += count;
        } catch (error) {
            console.error(`Error fetching plans at offset ${offset}:`, error);
            throw error;
        }
    }

    console.log(`Total plans found: ${allPlans.length}`);
    return allPlans;
}

/**
 * Deletes plans concurrently with concurrency control
 */
async function deletePlans(
    context: Context<EV>,
    plans: PlanAsResponse[],
): Promise<{ deleted: number; failed: number; errors: Array<{ planId: number; error: string }> }> {
    if (plans.length === 0) {
        console.log('No plans to delete');
        return { deleted: 0, failed: 0, errors: [] };
    }

    let deleted = 0;
    let failed = 0;
    const errors: Array<{ planId: number; error: string }> = [];

    const deleteTasks = plans.map((plan) => async () => {
        if (plan.id === undefined) {
            console.warn('Skipping plan without ID:', plan);
            return;
        }

        try {
            await TempoCloudTarget.Plan.deletePlanById({
                id: plan.id,
            });

            deleted++;
        } catch (error) {
            failed++;
            const errorMessage = error instanceof Error ? error.message : String(error);
            errors.push({ planId: plan.id, error: errorMessage });
            console.error(`Failed to delete plan ${plan.id}:`, errorMessage);
        }
    });

    const apiConcurrency = context.environment.vars.API_CONCURRENCY ?? 5;
    console.log(`Deleting ${plans.length} plans with concurrency limit: ${apiConcurrency}`);

    await throttleAll(apiConcurrency, deleteTasks);

    return { deleted, failed, errors };
}
TypeScriptGenerateDummyPlans

import TempoCloudSource from './api/tempo/cloud/source';
import JiraCloudSource from './api/jira/cloud/source';
import { throttleAll } from 'promise-throttle-all';

/**
 * Main function to generate dummy plans in the source instance
 */
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const startTime = Date.now();

    // Get current user who authorized the connector from Myself group
    const currentUser = await JiraCloudSource.Myself.getCurrentUser();
    const userAccountId = currentUser.accountId;
    if (!userAccountId) {
        throw new Error('Unable to get current user account ID');
    }

    // Get project by key
    const projectKey = context.environment.vars.Generator.PROJECT_KEY;
    if (!projectKey) {
        throw new Error('PROJECT_KEY is required in Generator parameters');
    }

    const project = await JiraCloudSource.Project.getProject({
        projectIdOrKey: projectKey,
    });

    const projectId = project.id;
    if (!projectId) {
        throw new Error(`Project with key ${projectKey} not found or does not have an ID`);
    }

    console.log('Generating dummy plans', {
        userAccountId,
        userDisplayName: currentUser.displayName,
        projectKey,
        projectId,
        toDate: context.environment.vars.TO_DATE,
        apiConcurrency: context.environment.vars.API_CONCURRENCY,
    });

    // Generate dates from today to TO_DATE
    const today = new Date();
    today.setHours(0, 0, 0, 0); // Start of today

    const toDate = new Date(context.environment.vars.TO_DATE);
    toDate.setHours(23, 59, 59, 999); // End of TO_DATE

    // Generate all dates from today to TO_DATE, but only for days 1-28
    const dates: Date[] = [];
    const currentDate = new Date(today);
    while (currentDate <= toDate) {
        const dayOfMonth = currentDate.getDate();
        // Only include days 1-28 (skip days 29, 30, 31)
        if (dayOfMonth >= 1 && dayOfMonth <= 28) {
            dates.push(new Date(currentDate));
        }
        currentDate.setDate(currentDate.getDate() + 1);
    }

    console.log(`Will create ${dates.length} plans (one per day from today to TO_DATE)`);

    // Create plan creation tasks
    const createPlanTasks = dates.map((date) => async () => {
        const planCreationTimestamp = Date.now();
        // Random hours between 1-3 (3600-10800 seconds)
        const randomSeconds = Math.floor(Math.random() * (10800 - 3600 + 1)) + 3600;
        // Random start time between 0 and 20 hours (0-72000 seconds)
        const randomStartTimeSeconds = Math.floor(Math.random() * (72000 + 1)); // 0 to 20 hours
        const startTime = formatTimeFromSeconds(randomStartTimeSeconds);

        const dateStr = formatDateForApi(date);

        try {
            const createdPlan = await TempoCloudSource.Plan.createPlan({
                body: {
                    assigneeId: userAccountId,
                    assigneeType: 'USER',
                    planItemId: projectId,
                    planItemType: 'PROJECT',
                    startDate: dateStr,
                    endDate: dateStr,
                    startTime: startTime,
                    effortPersistenceType: 'TOTAL_SECONDS',
                    plannedSeconds: randomSeconds,
                    description: `Dummy plan created at ${new Date(planCreationTimestamp).toISOString()}`,
                },
            });

            if (context.environment.vars.VERBOSE) {
                console.log(`Created plan for ${dateStr}: ${createdPlan.id} (${randomSeconds / 3600}h)`);
            }

            return {
                success: true,
                planId: createdPlan.id,
                date: dateStr,
                seconds: randomSeconds,
            };
        } catch (error) {
            console.error(`Failed to create plan for ${dateStr}:`, error);
            throw error;
        }
    });

    // Execute with concurrency limit
    const results = await throttleAll(context.environment.vars.API_CONCURRENCY, createPlanTasks);

    const successful = results.filter((r) => r.success).length;
    const failed = results.length - successful;
    const duration = Date.now() - startTime;

    console.log('Plan generation completed', {
        total: results.length,
        successful,
        failed,
        duration: `${(duration / 1000).toFixed(2)}s`,
    });

    if (failed > 0) {
        throw new Error(`Failed to create ${failed} out of ${results.length} plans`);
    }
}

/**
 * Formats a Date object to ISO date string (YYYY-MM-DD) for Tempo API
 */
function formatDateForApi(date: Date): string {
    return date.toISOString().split('T')[0];
}

/**
 * Formats seconds (0-72000) to HH:mm time format
 */
function formatTimeFromSeconds(seconds: number): string {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
TypeScriptGetMigrationReport

import { HttpEventRequest, HttpEventResponse, buildHTMLResponse, isText } from '@sr-connect/generic-app/events/http';
import { RecordStorage } from '@sr-connect/record-storage';
import type { MigrationState } from './Utils/Types';

const MIGRATION_STATE_KEY = 'tempo-plans-migration-state';
const STOP_MIGRATION_KEY = 'tempo-plans-migration-stop-request';

/**
 * Entry point for Sync HTTP Event
 */
export default async function (event: HttpEventRequest, context: Context<EV>): Promise<HttpEventResponse> {
    const storage = new RecordStorage();
    const migrationState = await storage.getValue<MigrationState>(MIGRATION_STATE_KEY);

    // Check if stop migration was requested via POST
    if (event.method === 'POST' && isText(event)) {
        const body = event.body as string;
        // Check if the Stop Migration button was pressed (form contains action=stop)
        if (body && body.includes('action=stop')) {
            // Store stop request in Record Storage (idempotent - will overwrite)
            await storage.setValue(STOP_MIGRATION_KEY, { requested: true, timestamp: Date.now() });
        }
    }

    // Check if stop request exists
    const stopRequest = await storage.getValue(STOP_MIGRATION_KEY);
    const isStopped = stopRequest !== undefined;

    const html = generateReport(migrationState, context, isStopped);
    return buildHTMLResponse(html);
}

/**
 * Formats a timestamp (number) to UTC string for display
 */
function formatTimestamp(timestamp: number | undefined): string {
    if (!timestamp) {
        return 'N/A';
    }
    const date = new Date(timestamp);
    return date.toUTCString().replace('GMT', 'UTC');
}

/**
 * Calculates time elapsed in a human-readable format
 */
function formatTimeElapsed(startTime: number, endTime?: number): string {
    const start = startTime;
    const end = endTime ?? Date.now();
    const elapsedMs = end - start;
    const elapsedSeconds = Math.floor(elapsedMs / 1000);
    const hours = Math.floor(elapsedSeconds / 3600);
    const minutes = Math.floor((elapsedSeconds % 3600) / 60);
    const seconds = elapsedSeconds % 60;

    if (hours > 0) {
        return `${hours}h ${minutes}m ${seconds}s`;
    }
    if (minutes > 0) {
        return `${minutes}m ${seconds}s`;
    }
    return `${seconds}s`;
}

/**
 * Formats a duration in milliseconds to a human-readable string (in seconds)
 */
function formatDuration(ms: number): string {
    const seconds = ms / 1000;
    return `${seconds.toFixed(1)}s`;
}

/**
 * Generates HTML report for migration progress
 */
function generateReport(
    migrationState: MigrationState | undefined,
    context: Context<EV>,
    isStopped: boolean = false,
): string {
    if (!migrationState) {
        return `
            <!DOCTYPE html>
            <html>
            <head>
                <title>Tempo Plans Migration Report</title>
                <style>
                    body { font-family: Arial, sans-serif; margin: 20px; }
                    h1 { color: #333; }
                    .info { color: #666; }
                </style>
            </head>
            <body>
                <h1>Tempo Plans Migration Report</h1>
                <p class="info">No migration state found. Migration has not been started yet.</p>
            </body>
            </html>
        `;
    }

    const batches = migrationState.batches ?? [];
    const failedItems = migrationState.failedItems ?? [];

    // Calculate summary statistics
    const timeElapsed = formatTimeElapsed(migrationState.startTime, migrationState.endTime);
    const batchesCompleted = migrationState.batchesCompleted;
    const averageBatchTime = batches.length > 0 ? batches.reduce((sum, b) => sum + b.timeSpent, 0) / batches.length : 0;

    // Calculate plans per second (overall)
    const totalTimeMs = migrationState.endTime
        ? migrationState.endTime - migrationState.startTime
        : Date.now() - migrationState.startTime;
    const plansPerSecond = totalTimeMs > 0 ? (migrationState.totalMigrated / totalTimeMs) * 1000 : 0;

    // Calculate plans per second per batch (only batch processing time)
    const totalBatchTimeMs = batches.reduce((sum, b) => sum + b.timeSpent, 0);
    const plansPerSecondPerBatch = totalBatchTimeMs > 0 ? (migrationState.totalMigrated / totalBatchTimeMs) * 1000 : 0;

    // Get throttle counts
    const tempoCloudThrottles = migrationState.throttleCounts.tempoCloud ?? 0;
    const srConnectThrottles = migrationState.throttleCounts.scriptRunnerConnect ?? 0;

    // Determine batches to display (most recent first)
    const maxDisplayedBatches = context.environment.vars.Report.MAX_DISPLAYED_BATCHES;
    const batchesToDisplay =
        maxDisplayedBatches === -1
            ? [...batches].reverse()
            : maxDisplayedBatches === 0
                ? []
                : [...batches].slice(-maxDisplayedBatches).reverse();

    // Determine failures to display (most recent first)
    const maxDisplayedFailures = context.environment.vars.Report.MAX_DISPLAYED_FAILURES;
    const failuresToDisplay =
        maxDisplayedFailures === -1
            ? [...failedItems].reverse()
            : maxDisplayedFailures === 0
                ? []
                : [...failedItems].slice(-maxDisplayedFailures).reverse();

    return `
        <!DOCTYPE html>
        <html>
        <head>
            <title>Tempo Plans Migration Report</title>
            <style>
                body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
                .container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
                h1 { color: #333; border-bottom: 2px solid #0065ff; padding-bottom: 10px; }
                h2 { color: #555; margin-top: 30px; border-bottom: 1px solid #ddd; padding-bottom: 5px; }
                .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin: 20px 0; }
                .summary-item { background-color: #f9f9f9; padding: 15px; border-radius: 4px; border-left: 4px solid #0065ff; }
                .summary-label { font-weight: bold; color: #666; font-size: 0.9em; margin-bottom: 5px; }
                .summary-value { font-size: 1.2em; color: #333; }
                table { width: 100%; border-collapse: collapse; margin: 20px 0; }
                th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
                th { background-color: #0065ff; color: white; font-weight: bold; }
                tr:hover { background-color: #f5f5f5; }
                .status-running { color: #0065ff; font-weight: bold; }
                .status-completed { color: #28a745; font-weight: bold; }
                .status-stopped { color: #dc3545; font-weight: bold; }
                .no-data { color: #999; font-style: italic; text-align: center; padding: 20px; }
                .stop-banner { background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; margin-bottom: 20px; }
                .stop-button { background-color: #dc3545; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 1em; margin-top: 20px; }
                .stop-button:hover { background-color: #c82333; }
                .stop-button:disabled { background-color: #6c757d; cursor: not-allowed; }
            </style>
        </head>
        <body>
            <div class="container">
                <h1>Tempo Plans Migration Report</h1>
                ${
                    isStopped && migrationState && !migrationState.endTime
                        ? '<div class="stop-banner">Migration has been stopped. It will remain stopped until you restart the migration. The actual migration scrip will stop after the current batch is completed.</div>'
                        : ''
                }
                ${
                    migrationState && !migrationState.endTime && !isStopped
                        ? '<form method="post" style="display: inline;"><input type="hidden" name="action" value="stop"><button type="submit" class="stop-button">Stop Migration</button></form>'
                        : ''
                }
                
                <h2>Summary</h2>
                <div class="summary">
                    <div class="summary-item">
                        <div class="summary-label">Status</div>
                        <div class="summary-value ${
                            migrationState?.endTime
                                ? 'status-completed'
                                : isStopped
                                    ? 'status-stopped'
                                    : 'status-running'
                        }">
                            ${migrationState?.endTime ? 'Completed' : isStopped ? 'Stopped' : 'Running'}
                        </div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Time Elapsed</div>
                        <div class="summary-value">${timeElapsed}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Migrated Plans</div>
                        <div class="summary-value">${migrationState.totalMigrated}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Failed Plans</div>
                        <div class="summary-value">${migrationState.failedItems?.length ?? 0}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Tempo Cloud Throttle Count</div>
                        <div class="summary-value">${tempoCloudThrottles}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">ScriptRunner Connect Throttle Count</div>
                        <div class="summary-value">${srConnectThrottles}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Batches Completed</div>
                        <div class="summary-value">${batchesCompleted}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Average Batch Processing Time</div>
                        <div class="summary-value">${formatDuration(averageBatchTime)}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Plans per Second</div>
                        <div class="summary-value">${plansPerSecond.toFixed(2)}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Plans per Second per Batch</div>
                        <div class="summary-value">${plansPerSecondPerBatch.toFixed(2)}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Last Updated</div>
                        <div class="summary-value">${formatTimestamp(migrationState.lastUpdated)}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">Start Time</div>
                        <div class="summary-value">${formatTimestamp(migrationState.startTime)}</div>
                    </div>
                    <div class="summary-item">
                        <div class="summary-label">End Time</div>
                        <div class="summary-value">${formatTimestamp(migrationState.endTime)}</div>
                    </div>
                </div>

                ${
                    batchesToDisplay.length > 0
                        ? `
                <h2>Batches</h2>
                <table>
                    <thead>
                        <tr>
                            <th>#</th>
                            <th>Batch Type</th>
                            <th>Migrated Plans</th>
                            <th>Failed Plans</th>
                            <th>Tempo Cloud Throttle Count</th>
                            <th>ScriptRunner Connect Throttle Count</th>
                            <th>Batch Time</th>
                            <th>Plans/Second</th>
                            <th>Completion Time</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${batchesToDisplay
                            .map(
                                (batch) => `
                            <tr>
                                <td>${batch.batchNumber}</td>
                                <td>${batch.batchType}</td>
                                <td>${batch.migrated}</td>
                                <td>${batch.failed}</td>
                                <td>${batch.throttleCounts.tempoCloud ?? 0}</td>
                                <td>${batch.throttleCounts.scriptRunnerConnect ?? 0}</td>
                                <td>${formatDuration(batch.timeSpent)}</td>
                                <td>${batch.migrated > 0 && batch.timeSpent > 0 ? (batch.migrated / (batch.timeSpent / 1000)).toFixed(2) : '0.00'}</td>
                                <td>${formatTimestamp(batch.completionTime)}</td>
                            </tr>
                        `,
                            )
                            .join('')}
                    </tbody>
                </table>
                `
                        : maxDisplayedBatches === 0
                            ? ''
                            : '<p class="no-data">No batches to display</p>'
                }

                ${
                    failuresToDisplay.length > 0
                        ? `
                <h2>Failures</h2>
                <table>
                    <thead>
                        <tr>
                            <th>#</th>
                            <th>Source ID</th>
                            <th>Time</th>
                            <th>Failure Reason</th>
                            <th>Additional Info</th>
                        </tr>
                    </thead>
                    <tbody>
                        ${failuresToDisplay
                            .map(
                                (failedItem) => `
                            <tr>
                                <td>${failedItem.entryNumber}</td>
                                <td>${failedItem.sourceId}</td>
                                <td>${formatTimestamp(failedItem.timestamp)}</td>
                                <td>${failedItem.reason}</td>
                                <td>${
                                    failedItem.additionalInfo
                                        ? Object.entries(failedItem.additionalInfo)
                                                .map(([key, value]) => `${key}: ${value ?? 'N/A'}`)
                                                .join(', ')
                                        : 'N/A'
                                }</td>
                            </tr>
                        `,
                            )
                            .join('')}
                    </tbody>
                </table>
                `
                        : maxDisplayedFailures === 0
                            ? ''
                            : '<p class="no-data">No failures to display</p>'
                }
            </div>
        </body>
        </html>
    `;
}
TypeScriptLogMigrationState

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

const MIGRATION_STATE_KEY = 'tempo-plans-migration-state';

/**
 * Logs the current migration state as raw JSON
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const storage = new RecordStorage();

    // Load migration state
    const migrationState = await storage.getValue<MigrationState>(MIGRATION_STATE_KEY);

    if (!migrationState) {
        console.log('No migration state found.');
        return;
    }

    // Log migration state as raw object
    console.log(migrationState);
}
TypeScriptMigratePlans

import TempoCloudSource from './api/tempo/cloud/source';
import TempoCloudTarget from './api/tempo/cloud/target';
import JiraCloudSource from './api/jira/cloud/source';
import JiraCloudTarget from './api/jira/cloud/target';
import { RecordStorage } from '@sr-connect/record-storage';
import { triggerScript } from '@sr-connect/trigger';
import { retry, TooManyRequestsError, NotFoundError, ForbiddenError } from '@managed-api/commons-core';
import { throttleAll } from 'promise-throttle-all';
import type { PlanAsResponse } from '@managed-api/tempo-cloud-v4-core/definitions/PlanAsResponse';
import type { MigrationState, BatchResult, FailedItem, ThrottleCounts, BatchState } from './Utils/Types';

const MIGRATION_STATE_KEY = 'tempo-plans-migration-state';
const STOP_MIGRATION_KEY = 'tempo-plans-migration-stop-request';

// Cache for issue lookups (ID -> key mapping for source, key -> ID mapping for target)
const issueKeyCache: Map<string, string> = new Map(); // source ID -> key
const issueIdCache: Map<string, string> = new Map(); // target key -> ID

// Cache for project lookups (ID -> key mapping for source, key -> ID mapping for target)
const projectKeyCache: Map<string, string> = new Map(); // source ID -> key
const projectIdCache: Map<string, string> = new Map(); // target key -> ID

// Cache for user lookups (accountId -> user info from source)
const userCache: Map<string, UserInfo> = new Map();

/**
 * Main migration function
 */
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const storage = new RecordStorage();

    // Load migration state from Record Storage
    let migrationState = await storage.getValue<MigrationState>(MIGRATION_STATE_KEY);

    // Clear stop request when script starts (allows migration to be stopped again if needed)
    await storage.deleteValue(STOP_MIGRATION_KEY);

    // Batch throttle counts (tracked per batch)
    const batchThrottleCounts: ThrottleCounts = {};

    // Format dates for API
    const fromDate = formatDateForApi(context.environment.vars.FROM_DATE);
    const toDate = formatDateForApi(context.environment.vars.TO_DATE);

    console.log('Migration parameters:', {
        fromDate,
        toDate,
        pageSize: context.environment.vars.PAGE_SIZE,
        apiConcurrency: context.environment.vars.API_CONCURRENCY,
        retryFailures: context.environment.vars.RETRY_FAILURES,
    });

    // Initialize state if not found
    if (!migrationState) {
        const now = Date.now();
        migrationState = {
            startTime: now,
            lastUpdated: now,
            paginationState: {
                offset: 0,
                hasMore: true,
            },
            totalMigrated: 0,
            batchesCompleted: 0,
            throttleCounts: {},
            batches: [],
            failedItems: [],
        };
        console.log('Starting new migration');
    } else {
        console.log('Resuming existing migration');
        // When running in retry mode, clear endTime to indicate migration is running again
        if (context.environment.vars.RETRY_FAILURES && migrationState.endTime) {
            delete migrationState.endTime;
        }
    }

    // Configure error handling - use setGlobalErrorStrategy on Managed API level
    const customErrorStrategy = createGlobalErrorStrategy(context, batchThrottleCounts);
    TempoCloudSource.setGlobalErrorStrategy(customErrorStrategy);
    TempoCloudTarget.setGlobalErrorStrategy(customErrorStrategy);

    // Create a clone of failed items before processing failed items
    // This clone will be used to process failed items, removing items as we process them
    // Start from the cursor position (offset) if it exists
    let clonedFailedItems: FailedItem[] | undefined;
    if (context.environment.vars.RETRY_FAILURES) {
        const failedItems = migrationState.failedItems ?? [];
        const offset = migrationState.paginationState?.offset ?? 0;
        // Start from the offset position to respect cursor position
        clonedFailedItems = failedItems.slice(offset);
    }

    // Main processing loop
    while (true) {
        // Check if stop migration was requested
        const stopRequest = await storage.getValue(STOP_MIGRATION_KEY);
        if (stopRequest) {
            // Keep stop request in Record Storage (don't delete it)
            console.log('Migration halted due to user request');
            // Save current state before halting
            await storage.setValue(MIGRATION_STATE_KEY, migrationState);
            break;
        }

        // Check if we have items to process when retrying failures
        if (context.environment.vars.RETRY_FAILURES) {
            if (!clonedFailedItems || clonedFailedItems.length === 0) {
                // Retry cycle has finished - set endTime (migration is complete unless restarted)
                // There may still be new failures, but the retry cycle is done
                migrationState.endTime = Date.now();
                await storage.setValue(MIGRATION_STATE_KEY, migrationState);
                console.log('Retry cycle completed - migration finished');
                break;
            }
        }

        // Step 1: Initialize batch
        const batchState = initializeBatch(batchThrottleCounts);

        // Step 2: Process batch
        const plansToProcess = await determineBatchSource(
            context,
            migrationState,
            fromDate,
            toDate,
            batchState,
            clonedFailedItems,
        );

        // If no plans to process, check completion
        if (plansToProcess.length === 0) {
            const isComplete = await checkCompletion(context, storage, migrationState, clonedFailedItems);
            if (isComplete) {
                break;
            }
            // If not complete but no plans, continue to next iteration
            continue;
        }

        const migrationResults = await migrateItems(context, plansToProcess, migrationState, batchState);

        // Step 3: Update migration state
        await updateMigrationState(storage, context, migrationState, batchState, migrationResults.batchTimeSpent);

        // Step 4: Check completion
        const isComplete = await checkCompletion(context, storage, migrationState, clonedFailedItems);
        if (isComplete) {
            break;
        }

        // Step 5: Check time remaining
        if (!isTimeLeftForNextBatch(context, migrationState)) {
            // Before restarting, adjust cursor position to match the last failed item
            if (context.environment.vars.RETRY_FAILURES && batchState.lastFailedItemId) {
                const failedItems = migrationState.failedItems ?? [];
                const lastFailedIndex = failedItems.findIndex((item) => item.sourceId === batchState.lastFailedItemId);
                if (lastFailedIndex >= 0) {
                    if (!migrationState.paginationState) {
                        migrationState.paginationState = {
                            offset: 0,
                            hasMore: true,
                        };
                    }
                    migrationState.paginationState.offset = lastFailedIndex + 1;
                }
            }
            console.log('Not enough time left for next batch, triggering script to continue');
            await storage.setValue(MIGRATION_STATE_KEY, migrationState);
            await triggerScript('MigratePlans');
            break;
        }
    }
}

/**
 * Step 1: Initialize batch - Reset batch counters and record start time
 */
function initializeBatch(batchThrottleCounts: ThrottleCounts): BatchState {
    batchThrottleCounts.tempoCloud = 0;
    batchThrottleCounts.scriptRunnerConnect = 0;

    return {
        batchMigrated: 0,
        batchFailed: 0,
        batchThrottleCounts,
        batchStartTime: Date.now(),
        batchFailedItems: [],
        successfullyRetriedSourceIds: [],
    };
}

/**
 * Step 2a: Determine batch source - Fetch plans from source or get failed items
 */
async function determineBatchSource(
    context: Context<EV>,
    migrationState: MigrationState,
    fromDate: string,
    toDate: string,
    batchState: BatchState,
    clonedFailedItems: FailedItem[] | undefined,
): Promise<PlanAsResponse[]> {
    if (context.environment.vars.RETRY_FAILURES) {
        return await getFailedItemsForRetry(context, fromDate, toDate, batchState, clonedFailedItems);
    } else {
        return await getNextBatchFromSource(context, migrationState, fromDate, toDate);
    }
}

/**
 * Get failed items for retry
 * Removes batch size from cloned list and processes it
 */
async function getFailedItemsForRetry(
    context: Context<EV>,
    fromDate: string,
    toDate: string,
    batchState: BatchState,
    clonedFailedItems: FailedItem[] | undefined,
): Promise<PlanAsResponse[]> {
    if (!clonedFailedItems || clonedFailedItems.length === 0) {
        return [];
    }

    // Get batch size items from the cloned list
    const batchSize = context.environment.vars.PAGE_SIZE;
    const itemsToRetry = clonedFailedItems.slice(0, batchSize);

    // Remove the batch size from the cloned list
    clonedFailedItems.splice(0, batchSize);

    // Track which items we're processing
    batchState.processedRetrySourceIds = itemsToRetry.map((f) => f.sourceId);

    try {
        const planIds = itemsToRetry.map((f) => parseInt(f.sourceId, 10)).filter((id) => !isNaN(id) && id > 0);

        if (planIds.length === 0) {
            return [];
        }

        const plansResponse = await TempoCloudSource.Plan.getPlans({
            from: fromDate,
            to: toDate,
            planIds,
            limit: batchSize,
        });

        const plans = plansResponse.results ?? [];
        return plans;
    } catch (error) {
        console.error('Error fetching failed plans for retry:', error);
        // If API call fails, add items back to the front of cloned list so they can be retried
        clonedFailedItems.unshift(...itemsToRetry);
        return [];
    }
}

/**
 * Get next batch of plans from source instance
 */
async function getNextBatchFromSource(
    context: Context<EV>,
    migrationState: MigrationState,
    fromDate: string,
    toDate: string,
): Promise<PlanAsResponse[]> {
    const offset = migrationState.paginationState?.offset ?? 0;
    try {
        const plansResponse = await TempoCloudSource.Plan.getPlans({
            from: fromDate,
            to: toDate,
            limit: context.environment.vars.PAGE_SIZE,
            offset,
        });

        const plans = plansResponse.results ?? [];

        // Update pagination state
        const count = plansResponse.metadata?.count ?? 0;
        const hasMore = count === context.environment.vars.PAGE_SIZE;
        migrationState.paginationState = {
            offset: offset + count,
            hasMore,
        };

        return plans;
    } catch (error) {
        console.error('Error fetching plans:', error);
        throw error;
    }
}

/**
 * Step 2b: Migrate items - Copy items from source to target with concurrency control
 */
async function migrateItems(
    context: Context<EV>,
    plansToProcess: PlanAsResponse[],
    migrationState: MigrationState,
    batchState: BatchState,
) {
    if (plansToProcess.length === 0) {
        return {
            batchTimeSpent: Date.now() - batchState.batchStartTime,
        };
    }

    let nextEntryNumber = (migrationState.failedItems?.length ?? 0) + 1;

    const createPlanTasks = plansToProcess.map((plan) => async () => {
        try {
            // Simulate failures based on configured percentage
            const simulatedFailureThreshold =
                context.environment.vars.Simulation?.PERCENTAGE_OF_SIMULATED_FAILURES ?? 0;
            if (simulatedFailureThreshold > 0) {
                const randomValue = Math.random();
                if (randomValue <= simulatedFailureThreshold) {
                    throw new Error('Simulated failure');
                }
            }

            const planInput = await convertPlanToInput(plan);
            const createdPlan = await TempoCloudTarget.Plan.createPlan({
                body: planInput,
            });

            batchState.batchMigrated++;

            // Track successfully retried items
            if (context.environment.vars.RETRY_FAILURES && plan.id !== undefined) {
                batchState.successfullyRetriedSourceIds.push(String(plan.id));
            }

            return {
                success: true,
                sourcePlanId: plan.id,
                targetPlanId: createdPlan.id,
            };
        } catch (error) {
            batchState.batchFailed++;
            const sourceId = plan.id !== undefined ? String(plan.id) : 'unknown';

            // Handle 403 Forbidden errors by fetching user information
            let errorMessage: string;
            if (error instanceof ForbiddenError && plan.assignee?.id) {
                try {
                    const userInfo = await getUserFromSource(plan.assignee.id);
                    errorMessage = `You do not have a permission to plan for user: ${userInfo.emailAddress ?? userInfo.displayName} (${userInfo.accountId})`;
                } catch (e) {
                    // If fetching user fails, fall back to original error message
                    errorMessage = error instanceof Error ? error.message : String(error);
                    if (context.environment.vars.VERBOSE) {
                        console.error('Error fetching user from source instance with account ID:', plan.assignee.id, e);
                    }
                }
            } else {
                errorMessage = error instanceof Error ? error.message : String(error);
            }

            // Check if this item already exists in migration state
            const existingItem = migrationState.failedItems?.find((item) => item.sourceId === sourceId);

            const failedItem: FailedItem = {
                entryNumber: existingItem?.entryNumber ?? nextEntryNumber++,
                sourceId,
                targetId: undefined,
                timestamp: Date.now(),
                reason: errorMessage,
                additionalInfo: {
                    description: plan.description,
                    assigneeId: plan.assignee?.id,
                    planItemId: plan.planItem?.id,
                },
            };

            batchState.batchFailedItems.push(failedItem);
            // Track the last failed item ID
            batchState.lastFailedItemId = sourceId;

            if (context.environment.vars.VERBOSE) {
                console.error(`Failed to migrate plan ${plan.id}:`, error);
            }

            if (context.environment.vars.HALT_WHEN_PLAN_MIGRATION_FAILS) {
                throw new Error(errorMessage);
            }

            return {
                success: false,
                failedItem,
            };
        }
    });

    // Execute with concurrency limit
    await throttleAll(context.environment.vars.API_CONCURRENCY, createPlanTasks);

    // Step 2c: Record batch metrics
    const batchTimeSpent = Date.now() - batchState.batchStartTime;

    return { batchTimeSpent };
}

/**
 * Step 3: Update migration state - Update state with batch results and save to Record Storage
 */
async function updateMigrationState(
    storage: RecordStorage,
    context: Context<EV>,
    migrationState: MigrationState,
    batchState: BatchState,
    batchTimeSpent: number,
) {
    // Create batch record
    const batch: BatchResult = {
        batchNumber: migrationState.batchesCompleted + 1,
        batchType: context.environment.vars.RETRY_FAILURES ? 'RETRY_FAILURES' : 'MIGRATE_ITEMS',
        migrated: batchState.batchMigrated,
        failed: batchState.batchFailed,
        throttleCounts: {
            tempoCloud: batchState.batchThrottleCounts.tempoCloud ?? 0,
            scriptRunnerConnect: batchState.batchThrottleCounts.scriptRunnerConnect ?? 0,
        },
        timeSpent: batchTimeSpent,
        completionTime: Date.now(),
    };

    // Update failed items list
    if (context.environment.vars.RETRY_FAILURES) {
        // When retrying failures:
        // 1. Remove successfully retried items from migration state
        // 2. Add new failures from this batch to migration state
        const successfulSourceIds = new Set(batchState.successfullyRetriedSourceIds ?? []);

        // Remove successfully retried items from migration state
        if (migrationState.failedItems && migrationState.failedItems.length > 0) {
            migrationState.failedItems = migrationState.failedItems.filter(
                (item) => !successfulSourceIds.has(item.sourceId),
            );
        }

        // Update or add failures from this batch to migration state
        if (batchState.batchFailedItems.length > 0) {
            const existingFailedItems = migrationState.failedItems ?? [];
            const existingSourceIds = new Set(existingFailedItems.map((item) => item.sourceId));

            // Update existing items or add new ones
            for (const failedItem of batchState.batchFailedItems) {
                const existingIndex = existingFailedItems.findIndex((item) => item.sourceId === failedItem.sourceId);
                if (existingIndex >= 0) {
                    // Update existing item
                    existingFailedItems[existingIndex] = failedItem;
                } else {
                    // Add new item
                    existingFailedItems.push(failedItem);
                }
            }

            migrationState.failedItems = existingFailedItems;
            if (context.environment.vars.VERBOSE) {
                const updatedCount = batchState.batchFailedItems.filter((item) =>
                    existingSourceIds.has(item.sourceId),
                ).length;
                const addedCount = batchState.batchFailedItems.length - updatedCount;
                console.log(`Updated ${updatedCount} and added ${addedCount} failed items to migration state`);
            }
        }
    } else {
        // Update or add failed items to the list
        const existingFailedItems = migrationState.failedItems ?? [];

        // Update existing items or add new ones
        for (const failedItem of batchState.batchFailedItems) {
            const existingIndex = existingFailedItems.findIndex((item) => item.sourceId === failedItem.sourceId);
            if (existingIndex >= 0) {
                // Update existing item
                existingFailedItems[existingIndex] = failedItem;
            } else {
                // Add new item
                existingFailedItems.push(failedItem);
            }
        }

        migrationState.failedItems = existingFailedItems;
    }

    // Update migration state
    migrationState.batches = [...(migrationState.batches ?? []), batch];
    migrationState.totalMigrated += batchState.batchMigrated;
    migrationState.batchesCompleted += 1;

    // Update throttle counts
    if (batchState.batchThrottleCounts.tempoCloud) {
        migrationState.throttleCounts.tempoCloud =
            (migrationState.throttleCounts.tempoCloud ?? 0) + batchState.batchThrottleCounts.tempoCloud;
    }
    if (batchState.batchThrottleCounts.scriptRunnerConnect) {
        migrationState.throttleCounts.scriptRunnerConnect =
            (migrationState.throttleCounts.scriptRunnerConnect ?? 0) +
            batchState.batchThrottleCounts.scriptRunnerConnect;
    }

    migrationState.lastUpdated = Date.now();

    // Log batch completion
    console.log('Batch completed:', {
        batchNumber: batch.batchNumber,
        batchType: batch.batchType,
        migrated: batch.migrated,
        failed: batch.failed,
        throttleCounts: batch.throttleCounts,
        timeSpent: `${(batch.timeSpent / 1000).toFixed(2)}s`,
    });

    // Store updated state
    await storage.setValue(MIGRATION_STATE_KEY, migrationState);
}

/**
 * Step 4: Check completion - Determine if migration is complete
 */
async function checkCompletion(
    context: Context<EV>,
    storage: RecordStorage,
    migrationState: MigrationState,
    clonedFailedItems: FailedItem[] | undefined,
) {
    if (context.environment.vars.RETRY_FAILURES) {
        // Check if there are more failures to retry in the cloned list
        // The cloned list is what we're actually processing from
        const remainingInClone = clonedFailedItems?.length ?? 0;

        // If clone is empty, retry cycle has finished - set endTime
        // Migration is considered complete unless restarted, even if there are new failures
        if (remainingInClone <= 0) {
            console.log('Retry cycle completed - no more failed plans to retry');
            migrationState.endTime = Date.now();
            await storage.setValue(MIGRATION_STATE_KEY, migrationState);
            return true;
        }
    } else {
        // Check if pagination has reached end
        if (!migrationState.paginationState?.hasMore) {
            console.log('No more plans to migrate');
            migrationState.endTime = Date.now();
            await storage.setValue(MIGRATION_STATE_KEY, migrationState);
            return true;
        }
    }

    return false;
}

/**
 * Step 5: Determines if there is enough time left to run the next batch
 */
function isTimeLeftForNextBatch(context: Context<EV>, migrationState: MigrationState): boolean {
    // If RESTART_SCRIPT_AFTER_FIRST_BATCH is enabled, restart after the first batch
    if (context.environment.vars.Simulation?.RESTART_SCRIPT_AFTER_FIRST_BATCH) {
        return false;
    }

    const batches = migrationState?.batches ?? [];

    // Calculate average batch time
    const averageBatchTime =
        batches.length > 0 ? batches.reduce((prev, current) => prev + current.timeSpent, 0) / batches.length : 0;

    // Calculate required time with multiplier
    let timeRequiredForNextBatch =
        averageBatchTime * context.environment.vars.Advanced.BATCH_CYCLE_CUTOFF_TIME_MULTIPLIER;

    // Enforce minimum time requirement
    if (timeRequiredForNextBatch < context.environment.vars.Advanced.BATCH_CYCLE_MIN_TIME * 1000) {
        timeRequiredForNextBatch = context.environment.vars.Advanced.BATCH_CYCLE_MIN_TIME * 1000;
    }

    // Check if time remains
    return context.startTime + context.timeout - timeRequiredForNextBatch > Date.now();
}

/**
 * Creates a global error strategy for handling 429 errors
 * Distinguishes between ScriptRunner Connect throttling and vendor throttling
 */
function createGlobalErrorStrategy(context: Context<EV>, batchThrottleCounts: ThrottleCounts) {
    return {
        handleHttp429Error: (
            error: TooManyRequestsError<unknown>,
            // eslint-disable-next-line @typescript-eslint/no-unused-vars
            attempt: number,
        ) => {
            // Check if the response headers include x-stitch-rate-limit
            const responseHeaders = error.response.headers;
            const isSrConnectThrottle = responseHeaders.get('x-stitch-rate-limit') !== null;

            if (isSrConnectThrottle) {
                // ScriptRunner Connect throttling
                batchThrottleCounts.scriptRunnerConnect = (batchThrottleCounts.scriptRunnerConnect ?? 0) + 1;
            } else {
                // Vendor (Tempo Cloud) throttling
                batchThrottleCounts.tempoCloud = (batchThrottleCounts.tempoCloud ?? 0) + 1;
            }

            // Check time availability
            const timeLeft =
                context.startTime + context.timeout - context.environment.vars.Advanced.RETRY_CUTOFF_TIME * 1000;
            const hasTime = timeLeft > Date.now();

            if (!hasTime) {
                // No time left, throw error
                throw new Error(
                    `Rate limited but no time left to retry. SR Connect throttles: ${batchThrottleCounts.scriptRunnerConnect ?? 0}, Tempo Cloud throttles: ${batchThrottleCounts.tempoCloud ?? 0}`,
                );
            }

            // Try to extract retry-after header if vendor supports it
            const retryAfterHeader = responseHeaders.get('retry-after') || responseHeaders.get('Retry-After');
            const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1000 : 1000;

            return retry(retryAfter);
        },
    };
}

/**
 * Gets the issue key from source instance by ID
 */
async function getIssueKeyFromSource(issueId: string): Promise<string> {
    // Check cache first
    if (issueKeyCache.has(issueId)) {
        return issueKeyCache.get(issueId)!;
    }

    try {
        const issue = await JiraCloudSource.Issue.getIssue({
            issueIdOrKey: issueId,
        });

        const key = issue.key;

        if (!key) {
            throw new Error(`Issue with ID ${issueId} does not have a key in source instance`);
        }

        // Cache the result
        issueKeyCache.set(issueId, key);
        return key;
    } catch (error) {
        if (error instanceof NotFoundError) {
            throw new Error(`Issue with ID ${issueId} not found in source instance`);
        }
        if (error instanceof Error) {
            throw error;
        }
        throw new Error(`Failed to get issue key from source instance: ${String(error)}`);
    }
}

/**
 * Gets the issue ID from target instance by key
 */
async function getIssueIdFromTarget(issueKey: string): Promise<string> {
    // Check cache first
    if (issueIdCache.has(issueKey)) {
        return issueIdCache.get(issueKey)!;
    }

    try {
        const issue = await JiraCloudTarget.Issue.getIssue({
            issueIdOrKey: issueKey,
        });

        const id = issue.id;

        if (!id) {
            throw new Error(`Issue with key ${issueKey} does not have an ID in target instance`);
        }

        // Cache the result
        issueIdCache.set(issueKey, id);
        return id;
    } catch (error) {
        if (error instanceof NotFoundError) {
            throw new Error(`Issue with key ${issueKey} not found in target instance`);
        }
        if (error instanceof Error) {
            throw error;
        }
        throw new Error(`Failed to get issue ID from target instance: ${String(error)}`);
    }
}

/**
 * Gets the project key from source instance by ID
 */
async function getProjectKeyFromSource(projectId: string): Promise<string> {
    // Check cache first
    if (projectKeyCache.has(projectId)) {
        return projectKeyCache.get(projectId)!;
    }

    try {
        const project = await JiraCloudSource.Project.getProject({
            projectIdOrKey: projectId,
        });

        const key = project.key;

        if (!key) {
            throw new Error(`Project with ID ${projectId} does not have a key in source instance`);
        }

        // Cache the result
        projectKeyCache.set(projectId, key);
        return key;
    } catch (error) {
        if (error instanceof NotFoundError) {
            throw new Error(`Project with ID ${projectId} not found in source instance`);
        }
        if (error instanceof Error) {
            throw error;
        }
        throw new Error(`Failed to get project key from source instance: ${String(error)}`);
    }
}

/**
 * Gets the project ID from target instance by key
 */
async function getProjectIdFromTarget(projectKey: string): Promise<string> {
    // Check cache first
    if (projectIdCache.has(projectKey)) {
        return projectIdCache.get(projectKey)!;
    }

    try {
        const project = await JiraCloudTarget.Project.getProject({
            projectIdOrKey: projectKey,
        });

        const id = project.id;

        if (!id) {
            throw new Error(`Project with key ${projectKey} does not have an ID in target instance`);
        }

        // Cache the result
        projectIdCache.set(projectKey, id);
        return id;
    } catch (error) {
        if (error instanceof NotFoundError) {
            throw new Error(`Project with key ${projectKey} not found in target instance`);
        }
        if (error instanceof Error) {
            throw error;
        }
        throw new Error(`Failed to get project ID from target instance: ${String(error)}`);
    }
}

/**
 * Gets user information from source instance by account ID
 */
async function getUserFromSource(accountId: string): Promise<UserInfo> {
    // Check cache first
    if (userCache.has(accountId)) {
        return userCache.get(accountId)!;
    }

    try {
        const user = await JiraCloudSource.User.getUser({
            accountId,
        });

        const userInfo = {
            emailAddress: user.emailAddress,
            displayName: user.displayName,
            accountId: user.accountId ?? accountId,
        };

        // Cache the result
        userCache.set(accountId, userInfo);
        return userInfo;
    } catch (error) {
        if (error instanceof NotFoundError) {
            throw new Error(`User with account ID ${accountId} not found in source instance`);
        }
        if (error instanceof Error) {
            throw error;
        }
        throw new Error(`Failed to get user from source instance: ${String(error)}`);
    }
}

/**
 * Resolves issue ID from source to target by looking up key and then target ID
 */
async function resolveIssueId(sourceIssueId: string): Promise<string> {
    // Step 1: Get key from source instance
    const issueKey = await getIssueKeyFromSource(sourceIssueId);

    // Step 2: Get ID from target instance using key
    const targetIssueId = await getIssueIdFromTarget(issueKey);

    return targetIssueId;
}

/**
 * Resolves project ID from source to target by looking up key and then target ID
 */
async function resolveProjectId(sourceProjectId: string): Promise<string> {
    // Step 1: Get key from source instance
    const projectKey = await getProjectKeyFromSource(sourceProjectId);

    // Step 2: Get ID from target instance using key
    const targetProjectId = await getProjectIdFromTarget(projectKey);

    return targetProjectId;
}

/**
 * Converts a Date object to ISO string format for Tempo API
 */
function formatDateForApi(date: Date): string {
    return date.toISOString().split('T')[0];
}

/**
 * Converts a PlanAsResponse to PlanInput for creating plans
 */
async function convertPlanToInput(plan: PlanAsResponse) {
    if (!plan.assignee?.id || !plan.assignee?.type) {
        throw new Error('Plan missing assignee information');
    }

    if (!plan.planItem?.id || !plan.planItem?.type) {
        throw new Error('Plan missing plan item information');
    }

    if (!plan.startDate || !plan.endDate) {
        throw new Error('Plan missing date information');
    }

    // Resolve plan item ID from source to target
    const planItemType = plan.planItem.type as 'ISSUE' | 'PROJECT';
    const targetPlanItemId =
        planItemType === 'ISSUE' ? await resolveIssueId(plan.planItem.id) : await resolveProjectId(plan.planItem.id);

    const planInput: Parameters<typeof TempoCloudTarget.Plan.createPlan>[0]['body'] = {
        assigneeId: plan.assignee.id,
        assigneeType: plan.assignee.type as 'USER' | 'GENERIC',
        endDate: plan.endDate,
        planItemId: targetPlanItemId,
        planItemType: planItemType,
        startDate: plan.startDate,
    };

    if (plan.description !== undefined) {
        planInput.description = plan.description;
    }

    if (plan.effortPersistenceType) {
        planInput.effortPersistenceType = plan.effortPersistenceType as 'SECONDS_PER_DAY' | 'TOTAL_SECONDS';
    }

    if (plan.includeNonWorkingDays !== undefined) {
        planInput.includeNonWorkingDays = plan.includeNonWorkingDays;
    }

    if (plan.planApproval) {
        // Convert planApproval: PlanApprovalAsResponse has reviewer object with accountId, but API requires reviewerId string
        const planApprovalInput: Parameters<typeof TempoCloudTarget.Plan.createPlan>[0]['body']['planApproval'] = {};

        // Extract reviewerId from reviewer.accountId (UserAsResponse has accountId, not id)
        if (plan.planApproval.reviewer?.accountId) {
            planApprovalInput.reviewerId = plan.planApproval.reviewer.accountId;
        }

        // Copy status if present
        if (plan.planApproval.status) {
            planApprovalInput.status = plan.planApproval.status;
        }

        // Only add planApproval if we have at least reviewerId
        if (planApprovalInput.reviewerId) {
            planInput.planApproval = planApprovalInput;
        }
    }

    // Set planned time based on effortPersistenceType:
    // - SECONDS_PER_DAY: use plannedSecondsPerDay
    // - TOTAL_SECONDS: use totalPlannedSeconds (mapped to plannedSeconds)
    // if (plan.effortPersistenceType === 'SECONDS_PER_DAY') {
    //     if (plan.plannedSecondsPerDay !== undefined) {
    //         planInput.plannedSecondsPerDay = plan.plannedSecondsPerDay;
    //     }
    // } else if (plan.effortPersistenceType === 'TOTAL_SECONDS') {
    //     if (plan.totalPlannedSeconds !== undefined) {
    //         planInput.plannedSeconds = plan.totalPlannedSeconds;
    //     }
    // }

    if (plan.totalPlannedSeconds !== undefined) {
        planInput.plannedSeconds = plan.totalPlannedSeconds;
    }

    if (plan.recurrenceEndDate) {
        planInput.recurrenceEndDate = plan.recurrenceEndDate;
    }

    if (plan.rule) {
        planInput.rule = plan.rule as 'NEVER' | 'WEEKLY' | 'BI_WEEKLY' | 'MONTHLY';
    }

    if (plan.startTime) {
        planInput.startTime = plan.startTime;
    }

    return planInput;
}

type UserInfo = {
    emailAddress?: string;
    displayName?: string;
    accountId?: string;
};
TypeScriptResetCursor

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

const MIGRATION_STATE_KEY = 'tempo-plans-migration-state';

/**
 * Resets the pagination state which needs to be executed prior to switching over to re-trying failed migrations.
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const storage = new RecordStorage();

    // Load migration state
    const migrationState = await storage.getValue<MigrationState>(MIGRATION_STATE_KEY);

    if (!migrationState) {
        console.log('No migration state found. Nothing to reset.');
        return;
    }

    // Reset pagination state to initial values
    migrationState.paginationState = {
        offset: 0,
        hasMore: true,
    };

    migrationState.lastUpdated = Date.now();

    // Save updated state
    await storage.setValue(MIGRATION_STATE_KEY, migrationState);

    console.log('Pagination state has been reset successfully');
    console.log('Migration will now start from the beginning, allowing failed items to be retried.');
}
TypeScriptResetMigration

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

const MIGRATION_STATE_KEY = 'tempo-plans-migration-state';
const STOP_MIGRATION_KEY = 'tempo-plans-migration-stop-request';

/**
 * Resets the migration by clearing the migration state from Record Storage
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default async function (event: unknown, context: Context<EV>): Promise<void> {
    const storage = new RecordStorage();

    // Check if migration state exists
    const exists = await storage.valueExists(MIGRATION_STATE_KEY);
    const stopRequestExists = await storage.valueExists(STOP_MIGRATION_KEY);

    if (exists) {
        // Delete the migration state
        await storage.deleteValue(MIGRATION_STATE_KEY);
        console.log('Migration state has been reset successfully');
    } else {
        console.log('No migration state found. Nothing to reset.');
    }

    if (stopRequestExists) {
        // Delete the stop migration request
        await storage.deleteValue(STOP_MIGRATION_KEY);
        console.log('Stop migration request has been cleared.');
    }
}
TypeScriptUtils/Types

/**
 * Type definitions for Tempo Cloud Plans Migration
 */

/**
 * Throttle counts for tracking rate limiting per vendor API and ScriptRunner Connect
 */
export interface ThrottleCounts {
    /**
     * ScriptRunner Connect throttle count
     */
    scriptRunnerConnect?: number;
    /**
     * Tempo Cloud API throttle count
     */
    tempoCloud?: number;
}

/**
 * Batch processing state
 */
export interface BatchState {
    /**
     * Number of items successfully migrated in this batch
     */
    batchMigrated: number;
    /**
     * Number of items that failed to migrate in this batch
     */
    batchFailed: number;
    /**
     * Throttle counts for this batch
     */
    batchThrottleCounts: ThrottleCounts;
    /**
     * Timestamp when batch processing started (milliseconds since epoch)
     */
    batchStartTime: number;
    /**
     * Failed items from this batch
     */
    batchFailedItems: FailedItem[];
    /**
     * Source IDs of items that were successfully retried (used when RETRY_FAILURES is enabled)
     */
    successfullyRetriedSourceIds: string[];
    /**
     * Source IDs of items that were processed in this batch (used when RETRY_FAILURES is enabled)
     * Used to track which items to remove from the clone list after processing
     */
    processedRetrySourceIds?: string[];
    /**
     * Source ID of the last item that failed in this batch
     */
    lastFailedItemId?: string;
}

/**
 * Failed item information for retry
 */
export interface FailedItem {
    /**
     * Minimum entry number for display
     */
    entryNumber: number;
    /**
     * Source plan ID from the source instance
     */
    sourceId: string;
    /**
     * Target plan ID if creation was attempted (may be undefined)
     */
    targetId?: string;
    /**
     * Timestamp when the failure occurred (number, milliseconds since epoch)
     */
    timestamp: number;
    /**
     * Reason for the failure
     */
    reason: string;
    /**
     * Additional identifying information to help identify the item (e.g., plan description, assignee)
     */
    additionalInfo?: Record<string, unknown>;
}

/**
 * Batch result information
 */
export interface BatchResult {
    /**
     * Batch number (starting from 1)
     */
    batchNumber: number;
    /**
     * Type of batch: 'MIGRATE_ITEMS' or 'RETRY_FAILURES'
     */
    batchType: 'MIGRATE_ITEMS' | 'RETRY_FAILURES';
    /**
     * Number of plans successfully migrated in this batch
     */
    migrated: number;
    /**
     * Number of plans that failed to migrate in this batch
     */
    failed: number;
    /**
     * Throttle counts for this batch (dictionary with vendor API names and scriptRunnerConnect)
     */
    throttleCounts: ThrottleCounts;
    /**
     * Time spent processing this batch in milliseconds
     */
    timeSpent: number;
    /**
     * Completion timestamp (number, milliseconds since epoch)
     */
    completionTime: number;
}

/**
 * Pagination state for tracking progress through source items
 */
export interface PaginationState {
    /**
     * Offset for pagination
     */
    offset?: number;
    /**
     * Whether there are more items to fetch
     */
    hasMore: boolean;
}

/**
 * Complete migration state stored in Record Storage
 */
export interface MigrationState {
    /**
     * Timestamp when migration started (number, milliseconds since epoch)
     */
    startTime: number;
    /**
     * Timestamp when migration completed (number, milliseconds since epoch, undefined if still running)
     */
    endTime?: number;
    /**
     * Timestamp of last state update (number, milliseconds since epoch)
     */
    lastUpdated: number;
    /**
     * Pagination state for tracking progress through source items
     */
    paginationState?: PaginationState;
    /**
     * Total number of plans successfully migrated
     */
    totalMigrated: number;
    /**
     * Number of batches processed
     */
    batchesCompleted: number;
    /**
     * Throttle counts (dictionary with vendor API names and scriptRunnerConnect)
     */
    throttleCounts: ThrottleCounts;
    /**
     * Array of all batches processed
     */
    batches: BatchResult[];
    /**
     * Array of all failed items (for retry)
     */
    failedItems: FailedItem[];
}

© 2025 ScriptRunner · Terms and Conditions · Privacy Policy · Legal Notice · Cookie Preferences