Template Content
Not the template you're looking for? Browse more.
About the template
About ScriptRunner Connect
What is ScriptRunner Connect?
Can I try it out for free?
Yes. ScriptRunner Connect comes with a forever free tier.
Can I customize the integration logic?
Absolutely. The main value proposition of ScriptRunner Connect is that you'll get full access to the code that is powering the integration, which means you can make any changes to the the integration logic yourself.
Can I change the integration to communicate with additional apps?
Yes. Since ScriptRunner Connect specializes in enabling complex integrations, you can easily change the integration logic to connect to as many additional apps as you need, no limitations.
What if I don't feel comfortable making changes to the code?
First you can try out our AI assistant which can help you understand what the code does, and also help you make changes to the code. Alternatively you can hire our professionals to make the changes you need or build new integrations from scratch.
Do I have to host it myself?
No. ScriptRunner Connect is a fully managed SaaS (Software-as-a-Service) product.
What about security?
ScriptRunner Connect is ISO 27001 and SOC 2 certified. Learn more about our security.
Template Content
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:
How it works:
Configure the following Connectors in ScriptRunner Connect:
For detailed connector setup instructions, refer to the ScriptRunner Connect web UI.
Configure the following API Connections in your workspace:
./api/tempo/cloud/source./api/tempo/cloud/target./api/jira/cloud/source./api/jira/cloud/targetConfigure one Generic Event Listener:
GetMigrationReportConfigure the following Parameters in the ScriptRunner Connect web UI:
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)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)RETRY_FAILURES (Boolean): When enabled, retries previously failed plans instead of migrating new onesHALT_WHEN_PLAN_MIGRATION_FAILS (Boolean): When enabled, stops migration immediately on first failureVERBOSE (Boolean): Enable detailed logging for debuggingMAX_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)⚠️ 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 batchBATCH_CYCLE_MIN_TIME (Number): Minimum seconds to reserve before starting next batchRETRY_CUTOFF_TIME (Number): Seconds before timeout to stop retrying rate-limited requestsDevelopment/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)FROM_DATE, TO_DATE, PAGE_SIZE, and API_CONCURRENCY based on your migration needsMigratePlans script manually from the ScriptRunner Connect web UINavigate to the Generic Event Listener URL configured for GetMigrationReport to view:
⚠️ Important: Refresh the reporting page regularly to see the latest migration updates. The page does not auto-refresh.
⚠️ 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:
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).
⚠️ 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.
RETRY_FAILURES parameter to trueResetCursor script first - this resets the retry position to the beginningMigratePlans scriptResetting 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:
ResetCursor script to reset the cursor positionMigratePlans again to start retrying from the beginning of the failed plans listTo start a fresh migration:
ResetMigration scriptMigratePlans: Main migration scriptGetMigrationReport: Generates HTML progress report (accessed via Generic Event Listener)ResetMigration: Clears migration state to start freshResetCursor: Resets pagination cursor positionLogMigrationState: Logs current migration state to consoleGenerateDummyPlans: Generates test plans in source instance (for testing)DeletePlansFromTarget: Deletes plans from target instance (for cleanup/testing)API_CONCURRENCY if you encounter frequent rate limitingMigratePlans againRETRY_FAILURES mode to retry failed plans after resolving underlying issuesPAGE_SIZE) affects migration speed and memory usageAPI_CONCURRENCY) speeds up migration but may trigger rate limits or 5xx errors from TempoPAGE_SIZE: 50-100, API_CONCURRENCY: 5-10FROM_DATE and TO_DATE are set correctlyVERBOSE: true for detailed error informationAPI_CONCURRENCY parameterPAGE_SIZE to reduce number of API callsLogMigrationState script to view current stateMigratePlans again to clear itResetMigration to start fresh if state is corruptedFROM_DATE and TO_DATE include the plans you want to migrateimport 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 };
}
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')}`;
}
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>
`;
}
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);
}
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;
};
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.');
}
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.');
}
}
/**
* 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