Epic link checker


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


Checks if the issue has epic link added after configured time period in Jira Cloud and notifies the user if it has not been. 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

TypeScriptEpicLinkChecker
Scheduled

README


Epic Link Checker

📋 Overview

The Epic Link Checker integration monitors newly created Jira issues and automatically notifies issue reporters when their issues haven't been linked to an Epic within a configurable time period. This helps maintain proper project organization by ensuring all issues are associated with the appropriate Epic for tracking purposes.

The integration works by:

  • Monitoring: Automatically detecting newly created Jira issues based on configurable filters
  • Tracking: Storing issue information temporarily while waiting for the validation delay period
  • Validating: Checking if issues have been linked to an Epic after the delay period
  • Notifying: Sending notifications to issue reporters when Epic links are missing

Business Value: Ensures consistent project organization, improves Epic-based reporting accuracy, and helps maintain proper agile planning structures by proactively addressing missing Epic links.

🖊️ Setup

Connectors

  • Jira Cloud Connector: Required for authenticating with your Jira Cloud instance. Configure this connector in the ScriptRunner Connect web UI with your Jira Cloud credentials.

API Connections

  • Jira Cloud API Connection: Links to the Jira Cloud Connector and provides access to Jira's REST API. This connection enables the script to search for issues, retrieve issue details, and send notifications.

Scheduled Triggers

  • Epic Link Checker Scheduled Trigger: Configure this trigger to run the EpicLinkChecker script on a regular schedule.
    • Recommended Schedule: Every 15-30 minutes for responsive monitoring
    • Minimum Schedule: 15 minutes (platform minimum)
    • Timezone: UTC (all schedules use UTC timezone)
    • Script: Link to EpicLinkChecker

Parameters

Configure the following Parameters in the ScriptRunner Connect web UI:

Parameter NameTypeRequiredDescriptionExample Value
PROJECT_FILTERListNoSpecific Jira project keys to monitor. Leave empty to monitor all projects.["PROJ1", "PROJ2"]
ISSUE_TYPE_FILTERListNoSpecific issue types to monitor (Story, Task, Bug, etc.). Leave empty to monitor all issue types.["Story", "Task"]
VALIDATION_DELAY_MINUTESNumberYesMinutes to wait after issue creation before checking for Epic link. Default: 1 minute.60

🚀 Using the Integration

Manual Testing

  1. Trigger Manually: Use the play button next to the EpicLinkChecker script in the Resource Manager to test immediately
  2. Create Test Issue: Create a new Jira issue in a monitored project without linking it to an Epic
  3. Wait for Delay: Allow the configured validation delay period to pass
  4. Run Script Again: Manually trigger the script to process the test issue

Monitoring Operation

  1. Check Script Invocation Logs: Review execution logs in the ScriptRunner Connect web UI to monitor performance
  2. Verify Notifications: Confirm that reporters receive notifications when Epic links are missing
  3. Review Issue Comments: Check for fallback comment notifications if direct notifications fail

Expected Outcomes

  • New Issues Tracked: Newly created issues are automatically detected and tracked
  • Epic Validation: Issues are checked for Epic links after the validation delay
  • Notifications Sent: Reporters receive notifications about missing Epic links via:
    • Primary: Direct Jira notifications
    • Fallback: Issue comments with @mentions
  • Automatic Cleanup: Processed issues are marked as complete and eventually removed from tracking

❗️ Considerations

  • Performance Impact: Large datasets (500+ issues) may impact performance. Use project and issue type filters to optimize execution time
  • Notification Permissions: Ensure the Jira Cloud Connector has permissions to send notifications and add comments to issues
  • Connector User Notifications: Notifications cannot be sent to the user who set up the Jira Cloud Connector. However, if the connector user creates an issue without an Epic link, a comment will still be created on the issue to remind them about the missing Epic link
  • Validation Delay Limit: Maximum validation delay is 24 hours (1440 minutes). Tracked issues are automatically deleted after 24 hours, so longer validation delays will not work as the tracking records expire before validation occurs
  • Storage Limits: Tracked issues consume Record Storage space. Issues are automatically cleaned up after 24 hours
  • Rate Limiting: The integration respects Jira Cloud API rate limits but may be affected during high-volume periods

🔧 Troubleshooting

Check Logs

  • Script Invocation Logs: Review execution logs in ScriptRunner Connect web UI for error details and processing information
  • HTTP Logs: Examine API calls to Jira Cloud for authentication or permission issues

Common Issues

No Issues Found

  • Verify PROJECT_FILTER and ISSUE_TYPE_FILTER parameters match actual Jira project keys and issue types
  • Check that the Jira Cloud API Connection is properly configured and authenticated
  • Ensure issues were created after the last script execution timestamp

Notification Failures

  • Confirm the Jira Cloud Connector has "Browse Projects" and "Create Comments" permissions
  • Verify reporter account IDs are valid and active in Jira
  • Check if direct notifications are enabled in Jira Cloud settings

Performance Issues

  • Add more specific PROJECT_FILTER values to reduce the number of issues processed
  • Increase VALIDATION_DELAY_MINUTES to reduce immediate processing load

Epic Link Detection

The integration checks for Epic links in two ways:

  • Parent Links: Modern Jira Cloud Epic relationships (recommended)
  • Epic Link Field: Legacy custom field (customfield_10014) for backward compatibility

If issues aren't being detected as having Epic links, verify your Jira configuration uses one of these methods for Epic associations.

API Connections


TypeScriptEpicLinkChecker

import JiraCloud from './api/jira/cloud';
import { RecordStorage } from '@sr-connect/record-storage';

/**
 * Epic Link Checker - monitors newly created Jira issues and notifies reporters
 * if they haven't linked their issue to an Epic within a configured time period.
 *
 * This integration helps maintain proper project organization by ensuring
 * all issues are associated with the appropriate Epic for tracking purposes.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default async function (_event: any, context: Context<EV>): Promise<void> {
    console.log('Starting Epic Link Checker...');

    const storage = new RecordStorage();
    const currentExecutionTime = new Date().toISOString();

    try {
        // Step 1: Find newly created issues since last execution
        await findAndTrackNewIssues(storage, context);

        // Step 2: Check tracked issues that are ready for Epic validation
        await processTrackedIssues(storage, context);

        // Step 3: Update the last execution timestamp
        await updateLastExecutionTimestamp(storage, currentExecutionTime);

        console.log('Epic Link Checker completed successfully');
    } catch (error) {
        console.error('Error in Epic Link Checker:', error);
        throw error;
    }
}

/**
 * Retrieves the timestamp of the last successful execution.
 * Falls back to a default lookback period for first-time runs.
 */
async function getLastExecutionTimestamp(storage: RecordStorage): Promise<string> {
    const lastExecutionRecord = await storage.getValue<LastExecutionRecord>('last_execution_timestamp');

    if (lastExecutionRecord?.timestamp) {
        console.log(`Last execution was at: ${lastExecutionRecord.timestamp}`);
        return lastExecutionRecord.timestamp;
    }

    // If no previous execution found, default to 15 minutes ago for the first run
    const defaultTimestamp = new Date(Date.now() - 15 * 60 * 1000).toISOString();
    console.log(`No previous execution found, using default timestamp: ${defaultTimestamp}`);
    return defaultTimestamp;
}

/**
 * Updates the last execution timestamp in storage for the next run
 */
async function updateLastExecutionTimestamp(storage: RecordStorage, executionTime: string): Promise<void> {
    const record: LastExecutionRecord = {
        timestamp: executionTime,
        executionTime: executionTime,
    };

    await storage.setValue('last_execution_timestamp', record, { ttl: 86400 }); // 24 hour TTL
    console.log(`Updated last execution timestamp to: ${executionTime}`);
}

/**
 * Searches for newly created issues since the last execution and starts tracking them.
 * Applies project and issue type filters if configured.
 */
async function findAndTrackNewIssues(storage: RecordStorage, context: Context<EV>): Promise<void> {
    console.log('Finding newly created issues...');

    // Get the timestamp from the last execution
    const lastExecutionTimestamp = await getLastExecutionTimestamp(storage);

    // Format timestamp for JQL (remove milliseconds and 'Z', replace 'T' with space)
    const formattedTimestamp = lastExecutionTimestamp.replace('T', ' ').substring(0, 16);

    // Build JQL query with optional project and issue type filters
    const jqlParts = [`created >= "${formattedTimestamp}"`];

    // Add project filter if specified
    const projectFilter = context.environment.vars.PROJECT_FILTER;
    if (projectFilter && projectFilter.length > 0) {
        const projects = projectFilter.filter((p) => p.trim() !== '');
        if (projects.length === 1) {
            jqlParts.push(`project = "${projects[0]}"`);
        } else if (projects.length > 1) {
            const projectList = projects.map((p) => `"${p}"`).join(', ');
            jqlParts.push(`project in (${projectList})`);
        }
    }

    // Add issue type filter if specified
    const issueTypeFilter = context.environment.vars.ISSUE_TYPE_FILTER;
    if (issueTypeFilter && issueTypeFilter.length > 0) {
        const issueTypes = issueTypeFilter.filter((t) => t.trim() !== '');
        if (issueTypes.length > 0) {
            const issueTypeList = issueTypes.map((t) => `"${t}"`).join(', ');
            jqlParts.push(`issuetype in (${issueTypeList})`);
        }
    }

    const jql = `${jqlParts.join(' AND ')} ORDER BY created DESC`;
    console.log(`Searching for issues with JQL: ${jql}`);

    // Handle pagination - fetch all pages of results
    const allIssues = [];
    let startAt = 0;
    const maxResults = 100;
    let totalFound = 0;

    do {
        console.log(`Fetching issues page: startAt=${startAt}, maxResults=${maxResults}`);

        const searchResult = await JiraCloud.Issue.Search.searchByJql({
            body: {
                jql: jql,
                fields: ['key', 'created', 'reporter'],
                startAt: startAt,
                maxResults: maxResults,
            },
        });

        if (!searchResult.issues || searchResult.issues.length === 0) {
            break;
        }

        allIssues.push(...searchResult.issues);
        totalFound = searchResult.total || 0;
        startAt += searchResult.issues.length;

        console.log(`Fetched ${searchResult.issues.length} issues from page (${allIssues.length}/${totalFound} total)`);

        // Performance consideration: warn if processing many API calls
        if (totalFound > 500) {
            console.warn(
                `Large dataset detected: ${totalFound} issues found. Consider using more restrictive filters to improve performance.`,
            );
        }
    } while (startAt < totalFound);

    if (allIssues.length === 0) {
        console.log('No newly created issues found');
        return;
    }

    console.log(`Found ${allIssues.length} newly created issues since last execution`);

    // Hardcode TTL to one day (86400 seconds)
    const trackingTtl = 86400;

    for (const issue of allIssues) {
        const issueKey = issue.key;
        if (!issueKey) {
            console.log('Issue has no key, skipping');
            continue;
        }

        const storageKey = `tracked_issue_${issueKey}`;

        // Check if we're already tracking this issue
        const existingRecord = await storage.getValue<TrackedIssue>(storageKey);
        if (existingRecord) {
            console.log(`Issue ${issueKey} already being tracked`);
            continue;
        }

        // Get reporter information
        const reporter = issue.fields?.reporter;
        if (!reporter || !reporter.accountId) {
            console.log(`Issue ${issueKey} has no reporter, skipping`);
            continue;
        }

        const trackedIssue: TrackedIssue = {
            issueKey: issueKey,
            reporterAccountId: reporter.accountId,
            reporterEmailAddress: reporter.emailAddress || '',
            createdTime: issue.fields?.created || new Date().toISOString(),
            processed: false,
        };

        await storage.setValue(storageKey, trackedIssue, { ttl: trackingTtl });
        console.log(`Started tracking issue ${issueKey}, created at ${trackedIssue.createdTime}`);
    }
}

/**
 * Processes tracked issues that are ready for Epic link validation.
 * Only checks issues that have been created for the configured validation delay period.
 */
async function processTrackedIssues(storage: RecordStorage, context: Context<EV>): Promise<void> {
    console.log('Processing tracked issues for Epic validation...');

    const allKeys = await storage.getAllKeys();
    const trackedIssueKeys = allKeys.keys?.filter((key) => key.startsWith('tracked_issue_')) || [];

    if (trackedIssueKeys.length === 0) {
        console.log('No tracked issues found');
        return;
    }

    // Get validation delay from environment variables with fallback (use default 1 minute if not configured)
    const validationDelayMinutes = context.environment.vars.VALIDATION_DELAY_MINUTES ?? 1;
    const validationDelayMs = validationDelayMinutes * 60 * 1000;
    const validationThreshold = Date.now() - validationDelayMs;

    for (const storageKey of trackedIssueKeys) {
        const trackedIssue = await storage.getValue<TrackedIssue>(storageKey);
        if (!trackedIssue || trackedIssue.processed) {
            continue;
        }

        const createdTime = new Date(trackedIssue.createdTime).getTime();

        // Check if the validation delay has passed since creation
        if (createdTime > validationThreshold) {
            console.log(
                `Issue ${trackedIssue.issueKey} not ready yet (created ${new Date(createdTime).toISOString()}, waiting ${validationDelayMinutes} minutes)`,
            );
            continue;
        }

        try {
            await checkEpicLinkAndNotify(trackedIssue);

            // Mark as processed
            trackedIssue.processed = true;
            const trackingTtl = 86400; // One day
            await storage.setValue(storageKey, trackedIssue, { ttl: trackingTtl });
        } catch (error) {
            console.error(`Error processing issue ${trackedIssue.issueKey}:`, error);
            // Don't mark as processed so we can retry later
        }
    }
}

/**
 * Checks if an issue has an Epic link and sends notification if missing.
 * Supports both parent links and custom Epic Link field (customfield_10014).
 * Skips Epic issues themselves since they don't need Epic links.
 */
async function checkEpicLinkAndNotify(trackedIssue: TrackedIssue): Promise<void> {
    console.log(`Checking Epic link for issue ${trackedIssue.issueKey}`);

    const issue = await JiraCloud.Issue.getIssue({
        issueIdOrKey: trackedIssue.issueKey,
        fields: ['summary', 'parent', 'customfield_10014', 'issuetype'],
    });

    // Check if this issue is an Epic itself - Epics don't need Epic links
    const isEpic = issue.fields?.issuetype?.name === 'Epic';
    if (isEpic) {
        console.log(`Issue ${trackedIssue.issueKey} is an Epic itself - no Epic link validation needed`);
        return;
    }

    const hasEpicLink = !!(issue.fields?.parent || issue.fields?.customfield_10014);

    if (hasEpicLink) {
        console.log(`Issue ${trackedIssue.issueKey} has Epic link - no notification needed`);
        return;
    }

    console.log(`Issue ${trackedIssue.issueKey} has no Epic link - sending notification to reporter`);

    // Send notification to reporter
    await sendEpicLinkNotification(trackedIssue, issue.fields?.summary || 'Unknown');
}

/**
 * Sends a notification to the issue reporter about missing Epic link.
 * Attempts direct notification first, falls back to issue comment if needed.
 */
async function sendEpicLinkNotification(trackedIssue: TrackedIssue, issueSummary: string): Promise<void> {
    // First try to send a direct notification to the reporter
    try {
        console.log(`Attempting to send direct notification to reporter for issue ${trackedIssue.issueKey}`);

        // Validate accountId before attempting notification
        if (!trackedIssue.reporterAccountId || trackedIssue.reporterAccountId.trim() === '') {
            throw new Error('Reporter accountId is empty or invalid');
        }

        await JiraCloud.Issue.sendNotification({
            issueIdOrKey: trackedIssue.issueKey,
            body: {
                subject: `Epic Link Required for Issue ${trackedIssue.issueKey}`,
                textBody: `Your issue "${issueSummary}" (${trackedIssue.issueKey}) does not have an Epic linked to it. Please add an Epic link to ensure proper project organization and tracking.`,
                htmlBody: `<p>Your issue "<strong>${issueSummary}</strong>" (<a href="/browse/${trackedIssue.issueKey}">${trackedIssue.issueKey}</a>) does not have an Epic linked to it.</p><p>Please add an Epic link to ensure proper project organization and tracking.</p>`,
                to: {
                    reporter: true,
                },
            },
        });

        console.log(`Direct notification sent successfully to reporter for issue ${trackedIssue.issueKey}`);
        return;
    } catch (notificationError) {
        console.warn(`Failed to send direct notification for issue ${trackedIssue.issueKey}:`, notificationError);
        console.log(`Falling back to comment notification for issue ${trackedIssue.issueKey}`);
    }

    // Fallback: Send notification via issue comment with mention to reporter
    try {
        const commentBody = {
            type: 'doc' as const,
            version: 1 as const,
            content: [
                {
                    type: 'paragraph' as const,
                    content: [
                        {
                            type: 'mention' as const,
                            attrs: {
                                id: trackedIssue.reporterAccountId,
                            },
                        },
                        {
                            type: 'text' as const,
                            text: ' Please link this issue to an Epic for proper project tracking.',
                        },
                    ],
                },
                {
                    type: 'paragraph' as const,
                    content: [
                        {
                            type: 'text' as const,
                            text: `Your issue "${issueSummary}" does not have an Epic linked to it. Please add an Epic link to ensure proper project organization.`,
                        },
                    ],
                },
            ],
        };

        await JiraCloud.Issue.Comment.addComment({
            issueIdOrKey: trackedIssue.issueKey,
            body: {
                body: commentBody,
            },
        });

        console.log(`Fallback comment notification added to issue ${trackedIssue.issueKey}`);
    } catch (commentError) {
        console.error(`Failed to add comment notification to issue ${trackedIssue.issueKey}:`, commentError);
        throw commentError;
    }
}

/**
 * Represents an issue being tracked for Epic link validation
 */
interface TrackedIssue {
    issueKey: string;
    reporterAccountId: string;
    reporterEmailAddress: string;
    createdTime: string;
    processed: boolean;
}

/**
 * Stores the timestamp of the last script execution
 */
interface LastExecutionRecord {
    timestamp: string;
    executionTime: string;
}

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