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.
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:
Business Value: Ensures consistent project organization, improves Epic-based reporting accuracy, and helps maintain proper agile planning structures by proactively addressing missing Epic links.
EpicLinkChecker
script on a regular schedule.EpicLinkChecker
Configure the following Parameters in the ScriptRunner Connect web UI:
Parameter Name | Type | Required | Description | Example Value |
---|---|---|---|---|
PROJECT_FILTER | List | No | Specific Jira project keys to monitor. Leave empty to monitor all projects. | ["PROJ1", "PROJ2"] |
ISSUE_TYPE_FILTER | List | No | Specific issue types to monitor (Story, Task, Bug, etc.). Leave empty to monitor all issue types. | ["Story", "Task"] |
VALIDATION_DELAY_MINUTES | Number | Yes | Minutes to wait after issue creation before checking for Epic link. Default: 1 minute. | 60 |
EpicLinkChecker
script in the Resource Manager to test immediatelyNo Issues Found
Notification Failures
Performance Issues
The integration checks for Epic links in two ways:
customfield_10014
) for backward compatibilityIf issues aren't being detected as having Epic links, verify your Jira configuration uses one of these methods for Epic associations.
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