diff --git a/tmp/list-custom-fields.js b/tmp/list-custom-fields.js deleted file mode 100644 index 3a9564e..0000000 --- a/tmp/list-custom-fields.js +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -/** - * List all custom fields in your Jira instance - * - * This script helps you discover the correct custom field IDs for your Jira instance. - * Run: node utils/list-custom-fields.js - */ - -require('dotenv').config() -const Jira = require('../utils/jira') - -async function listAllCustomFields () { - const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env - - if (!JIRA_BASE_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) { - console.error('Error: Missing required environment variables') - console.error('Please ensure JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN are set in your .env file') - process.exit(1) - } - - const jiraUtil = new Jira({ - baseUrl: JIRA_BASE_URL, - email: JIRA_EMAIL, - apiToken: JIRA_API_TOKEN, - }) - - try { - console.log('Fetching all custom fields from Jira...\n') - - const response = await jiraUtil.request('/field') - const fields = await response.json() - - // Filter for custom fields only - const customFields = fields.filter(field => field.id.startsWith('customfield_')) - - console.log(`Found ${customFields.length} custom fields:\n`) - console.log('=' .repeat(100)) - - // Group by name patterns we're looking for - const releaseFields = customFields.filter(f => - f.name.toLowerCase().includes('release') || - f.name.toLowerCase().includes('environment') || - f.name.toLowerCase().includes('timestamp') || - f.name.toLowerCase().includes('deploy') - ) - - if (releaseFields.length > 0) { - console.log('\n๐ŸŽฏ RELEASE/DEPLOYMENT RELATED FIELDS:') - console.log('=' .repeat(100)) - releaseFields.forEach(field => { - console.log(`ID: ${field.id}`) - console.log(`Name: ${field.name}`) - console.log(`Type: ${field.schema?.type || 'unknown'}`) - console.log(`Custom: ${field.schema?.custom || 'N/A'}`) - console.log('-'.repeat(100)) - }) - } - - console.log('\n๐Ÿ“‹ ALL CUSTOM FIELDS:') - console.log('=' .repeat(100)) - customFields.forEach(field => { - console.log(`${field.id.padEnd(20)} | ${field.name.padEnd(40)} | ${field.schema?.type || 'unknown'}`) - }) - - // Check specifically for the fields we're trying to use - console.log('\n\n๐Ÿ” CHECKING FOR EXPECTED FIELDS:') - console.log('=' .repeat(100)) - const expectedFields = [ - { id: 'customfield_11473', name: 'Release Environment' }, - { id: 'customfield_11474', name: 'Stage Release Timestamp' }, - { id: 'customfield_11475', name: 'Production Release Timestamp' }, - ] - - expectedFields.forEach(expected => { - const found = customFields.find(f => f.id === expected.id) - if (found) { - console.log(`โœ… ${expected.id} - Found: "${found.name}"`) - } else { - console.log(`โŒ ${expected.id} - NOT FOUND (expected: "${expected.name}")`) - } - }) - - } catch (error) { - console.error('Error fetching custom fields:', error.message) - process.exit(1) - } -} - -listAllCustomFields() diff --git a/tmp/test-custom-fields.js b/tmp/test-custom-fields.js deleted file mode 100644 index c31512d..0000000 --- a/tmp/test-custom-fields.js +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -/** - * Test script to verify custom field updates on a Jira issue - * - * This script tests the ability to update deployment-related custom fields - * on a specific Jira issue (DEX-36 by default) and optionally rolls back changes. - * - * Usage: - * node utils/test-custom-fields.js [ISSUE_KEY] - * - * Example: - * node utils/test-custom-fields.js DEX-36 - */ - -require('dotenv').config() -const Jira = require('../utils/jira') -const readline = require('readline') - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}) - -function question (prompt) { - return new Promise((resolve) => { - rl.question(prompt, resolve) - }) -} - -async function testCustomFieldUpdates () { - const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env - const issueKey = process.argv[2] || 'DEX-36' - - if (!JIRA_BASE_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) { - console.error('โŒ Error: Missing required environment variables') - console.error('Please ensure JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN are set in your .env file') - process.exit(1) - } - - console.log(`\n${ '='.repeat(80)}`) - console.log('๐Ÿงช JIRA CUSTOM FIELDS UPDATE TEST') - console.log('='.repeat(80)) - console.log(`Issue: ${issueKey}`) - console.log(`Jira URL: ${JIRA_BASE_URL}`) - console.log(`${'='.repeat(80) }\n`) - - const jiraUtil = new Jira({ - baseUrl: JIRA_BASE_URL, - email: JIRA_EMAIL, - apiToken: JIRA_API_TOKEN, - }) - - let originalValues = {} - - try { - // Step 1: Capture original values - console.log('๐Ÿ“‹ Step 1: Capturing original field values...\n') - - const issueResponse = await jiraUtil.request( - `/issue/${issueKey}?fields=status,customfield_11473,customfield_11474,customfield_11475` - ) - const issue = await issueResponse.json() - - originalValues = { - status: issue.fields.status.name, - releaseEnvironment: issue.fields.customfield_11473, - stageReleaseTimestamp: issue.fields.customfield_11474, - productionReleaseTimestamp: issue.fields.customfield_11475, - } - - console.log('Current Status:', originalValues.status) - console.log('Release Environment:', originalValues.releaseEnvironment ? JSON.stringify(originalValues.releaseEnvironment) : 'null') - console.log('Stage Release Timestamp:', originalValues.stageReleaseTimestamp || 'null') - console.log('Production Release Timestamp:', originalValues.productionReleaseTimestamp || 'null') - console.log() - - // Step 2: Test updating custom fields - console.log('๐Ÿ“ Step 2: Testing custom field updates...\n') - - const testTimestamp = new Date().toISOString() - const testCustomFields = { - customfield_11474: testTimestamp, // Stage Release Timestamp - customfield_11473: { id: '11942' }, // Release Environment: staging - } - - console.log('Test values to set:') - console.log(' - Stage Release Timestamp:', testTimestamp) - console.log(' - Release Environment: staging (ID: 11942)') - console.log() - - await jiraUtil.updateCustomFields(issueKey, testCustomFields) - - console.log('โœ… Custom fields updated successfully!\n') - - // Step 3: Verify the update - console.log('๐Ÿ” Step 3: Verifying the update...\n') - - await new Promise(resolve => setTimeout(resolve, 1000)) // Wait for Jira to process - - const verifyResponse = await jiraUtil.request( - `/issue/${issueKey}?fields=customfield_11473,customfield_11474,customfield_11475` - ) - const verifiedIssue = await verifyResponse.json() - - console.log('Updated values:') - console.log('Release Environment:', verifiedIssue.fields.customfield_11473 ? JSON.stringify(verifiedIssue.fields.customfield_11473) : 'null') - console.log('Stage Release Timestamp:', verifiedIssue.fields.customfield_11474 || 'null') - console.log('Production Release Timestamp:', verifiedIssue.fields.customfield_11475 || 'null') - console.log() - - // Step 4: Test production custom fields - console.log('๐Ÿ“ Step 4: Testing production custom field update...\n') - - const prodTestTimestamp = new Date().toISOString() - const prodCustomFields = { - customfield_11475: prodTestTimestamp, // Production Release Timestamp - customfield_11473: { id: '11943' }, // Release Environment: production - } - - console.log('Production test values:') - console.log(' - Production Release Timestamp:', prodTestTimestamp) - console.log(' - Release Environment: production (ID: 11943)') - console.log() - - await jiraUtil.updateCustomFields(issueKey, prodCustomFields) - - console.log('โœ… Production custom fields updated successfully!\n') - - // Step 5: Verify production update - console.log('๐Ÿ” Step 5: Verifying production update...\n') - - await new Promise(resolve => setTimeout(resolve, 1000)) - - const verifyProdResponse = await jiraUtil.request( - `/issue/${issueKey}?fields=customfield_11473,customfield_11474,customfield_11475` - ) - const verifiedProdIssue = await verifyProdResponse.json() - - console.log('Final values:') - console.log('Release Environment:', verifiedProdIssue.fields.customfield_11473 ? JSON.stringify(verifiedProdIssue.fields.customfield_11473) : 'null') - console.log('Stage Release Timestamp:', verifiedProdIssue.fields.customfield_11474 || 'null') - console.log('Production Release Timestamp:', verifiedProdIssue.fields.customfield_11475 || 'null') - console.log() - - // Step 6: Offer to rollback - console.log('='.repeat(80)) - console.log('โœ… ALL TESTS PASSED!') - console.log(`${'='.repeat(80) }\n`) - - const shouldRollback = await question('Would you like to rollback to original values? (y/n): ') - - if (shouldRollback.toLowerCase() === 'y') { - console.log('\nโฎ๏ธ Rolling back changes...\n') - - const rollbackFields = {} - - if (originalValues.releaseEnvironment) { - rollbackFields.customfield_11473 = originalValues.releaseEnvironment - } - if (originalValues.stageReleaseTimestamp) { - rollbackFields.customfield_11474 = originalValues.stageReleaseTimestamp - } - if (originalValues.productionReleaseTimestamp) { - rollbackFields.customfield_11475 = originalValues.productionReleaseTimestamp - } - - if (Object.keys(rollbackFields).length > 0) { - await jiraUtil.updateCustomFields(issueKey, rollbackFields) - console.log('โœ… Successfully rolled back to original values') - } else { - console.log('โ„น๏ธ No original values to restore (fields were empty)') - } - } else { - console.log('\nโš ๏ธ Changes were NOT rolled back. The test values remain on the issue.') - } - - console.log(`\n${ '='.repeat(80)}`) - console.log('๐ŸŽ‰ Test completed successfully!') - console.log(`${'='.repeat(80) }\n`) - - } catch (error) { - console.error('\nโŒ TEST FAILED!') - console.error('Error:', error.message) - console.error('\nDetails:', error) - process.exit(1) - } finally { - rl.close() - } -} - -// Run the test -testCustomFieldUpdates() diff --git a/tmp/verify-staging-flow.js b/tmp/verify-staging-flow.js deleted file mode 100644 index faec186..0000000 --- a/tmp/verify-staging-flow.js +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -/** - * Verify the complete staging deployment flow for custom field updates - * - * This script simulates what happens in the GitHub Actions pipeline when - * code is deployed to staging, to verify that custom fields will be updated. - * - * Usage: node utils/verify-staging-flow.js [ISSUE_KEY] - * Example: node utils/verify-staging-flow.js DEX-36 - */ - -require('dotenv').config() -const Jira = require('../utils/jira') - -// Import the status configuration (simulating what's in index.js) -const stagingReleaseEnvId = '11942' // Option ID for "staging" -const stagingConfig = { - status: 'Deployed to Staging', - transitionFields: { - // No resolution field - "Deployed to Staging" is not a final state - }, - customFields: { - customfield_11474: new Date(), - customfield_11473: { id: stagingReleaseEnvId }, - }, -} - -async function verifyFlow () { - const issueKey = process.argv[2] || 'DEX-36' - const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env - - if (!JIRA_BASE_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) { - console.error('โŒ Missing environment variables') - process.exit(1) - } - - console.log(`\n${ '='.repeat(80)}`) - console.log('๐Ÿ” VERIFYING STAGING DEPLOYMENT FLOW') - console.log('='.repeat(80)) - console.log(`Issue: ${issueKey}`) - console.log(`${'='.repeat(80) }\n`) - - const jiraUtil = new Jira({ - baseUrl: JIRA_BASE_URL, - email: JIRA_EMAIL, - apiToken: JIRA_API_TOKEN, - }) - - try { - // Step 1: Show current state - console.log('๐Ÿ“‹ Current Issue State:') - const issueResponse = await jiraUtil.request( - `/issue/${issueKey}?fields=status,customfield_11473,customfield_11474` - ) - const issue = await issueResponse.json() - console.log(` Status: ${issue.fields.status.name}`) - console.log(` Release Environment: ${issue.fields.customfield_11473?.value || 'null'}`) - console.log(` Stage Release Timestamp: ${issue.fields.customfield_11474 || 'null'}`) - console.log() - - // Step 2: Simulate what prepareFields does - console.log('๐Ÿ“ Step 1: Prepare transition fields') - console.log(' Input transitionFields:', JSON.stringify(stagingConfig.transitionFields)) - const preparedFields = {} - for (const [ fieldName, fieldValue ] of Object.entries(stagingConfig.transitionFields)) { - preparedFields[fieldName] = fieldValue - } - console.log(' Output preparedFields:', JSON.stringify(preparedFields)) - console.log(' โœ… No resolution field will be sent in transition') - console.log() - - // Step 3: Simulate transition (without actually doing it) - console.log('๐Ÿ“ Step 2: Transition issue (simulation)') - console.log(` Target status: ${stagingConfig.status}`) - console.log(` Fields to send: ${Object.keys(preparedFields).length === 0 ? 'NONE' : JSON.stringify(preparedFields)}`) - console.log(' โœ… Transition will succeed (no resolution field)') - console.log() - - // Step 4: Show what custom fields will be updated - console.log('๐Ÿ“ Step 3: Update custom fields') - console.log(' Custom fields to update:') - for (const [ fieldId, fieldValue ] of Object.entries(stagingConfig.customFields)) { - const fieldName = fieldId === 'customfield_11474' ? 'Stage Release Timestamp' : 'Release Environment' - console.log(` - ${fieldName} (${fieldId}):`, - typeof fieldValue === 'object' && fieldValue instanceof Date - ? fieldValue.toISOString() - : JSON.stringify(fieldValue) - ) - } - console.log(` โœ… ${Object.keys(stagingConfig.customFields).length} custom fields will be updated`) - console.log() - - // Step 5: Verify custom fields exist and can be updated - console.log('๐Ÿ“ Step 4: Verify field configuration') - const allFieldsResponse = await jiraUtil.request('/field') - const allFields = await allFieldsResponse.json() - - const field11473 = allFields.find(f => f.id === 'customfield_11473') - const field11474 = allFields.find(f => f.id === 'customfield_11474') - - console.log(' customfield_11473:', field11473 ? `โœ… ${field11473.name}` : 'โŒ NOT FOUND') - console.log(' customfield_11474:', field11474 ? `โœ… ${field11474.name}` : 'โŒ NOT FOUND') - console.log() - - // Step 6: Summary - console.log('='.repeat(80)) - console.log('โœ… VERIFICATION COMPLETE') - console.log('='.repeat(80)) - console.log('Flow Summary:') - console.log(' 1. transitionFields is empty โ†’ No resolution error โœ…') - console.log(' 2. Transition to "Deployed to Staging" will succeed โœ…') - console.log(' 3. Custom fields will be updated separately โœ…') - console.log(' 4. Stage Release Timestamp will be set โœ…') - console.log(' 5. Release Environment will be set to "Staging" โœ…') - console.log() - console.log('๐ŸŽ‰ The pipeline WILL update custom fields correctly!') - console.log(`${'='.repeat(80) }\n`) - - } catch (error) { - console.error('\nโŒ VERIFICATION FAILED!') - console.error('Error:', error.message) - process.exit(1) - } -} - -verifyFlow() diff --git a/update_jira/index.js b/update_jira/index.js index a4bb91f..747a40d 100644 --- a/update_jira/index.js +++ b/update_jira/index.js @@ -9,7 +9,6 @@ require('dotenv').config() const core = require('@actions/core') -const github = require('@actions/github') const { Octokit } = require('@octokit/rest') const Jira = require('./../utils/jira') const fs = require('node:fs') @@ -72,9 +71,8 @@ const ACTION_CONSTANTS = { }, COMMIT_HISTORY: { - PRODUCTION_RANGE: 'HEAD~100', - STAGING_RANGE: 'HEAD~50', - HEAD: 'HEAD', + PRODUCTION_MAX_COMMITS: 200, + STAGING_MAX_COMMITS: 200, }, VALIDATION: { @@ -103,9 +101,7 @@ const ACTION_CONSTANTS = { const STATUS_MAP = { master: { status: ACTION_CONSTANTS.JIRA_STATUSES.DONE, - transitionFields: { - resolution: 'Done', - }, + transitionFields: {}, customFields: { [ACTION_CONSTANTS.CUSTOM_FIELDS.PRODUCTION_TIMESTAMP]: () => new Date(), [ACTION_CONSTANTS.CUSTOM_FIELDS.RELEASE_ENVIRONMENT]: { id: ACTION_CONSTANTS.RELEASE_ENV_IDS.PRODUCTION }, @@ -113,9 +109,7 @@ const STATUS_MAP = { }, main: { status: ACTION_CONSTANTS.JIRA_STATUSES.DONE, - transitionFields: { - resolution: 'Done', - }, + transitionFields: {}, customFields: { [ACTION_CONSTANTS.CUSTOM_FIELDS.PRODUCTION_TIMESTAMP]: () => new Date(), [ACTION_CONSTANTS.CUSTOM_FIELDS.RELEASE_ENVIRONMENT]: { id: ACTION_CONSTANTS.RELEASE_ENV_IDS.PRODUCTION }, @@ -123,9 +117,7 @@ const STATUS_MAP = { }, staging: { status: ACTION_CONSTANTS.JIRA_STATUSES.DEPLOYED_TO_STAGING, - transitionFields: { - resolution: 'Done', - }, + transitionFields: {}, customFields: { [ACTION_CONSTANTS.CUSTOM_FIELDS.STAGING_TIMESTAMP]: () => new Date(), [ACTION_CONSTANTS.CUSTOM_FIELDS.RELEASE_ENVIRONMENT]: { id: ACTION_CONSTANTS.RELEASE_ENV_IDS.STAGING }, @@ -655,6 +647,268 @@ function extractPrNumber (commitMessage) { return prMatch ? prMatch[1] : null } +/** + * Fetch commits from GitHub API and extract issue keys, stopping when consecutive + * tickets are already in "Done" status (smart iteration to handle out-of-band releases) + * + * NOTE: Alternative future optimization suggested by Damian: + * - Store SHA of last successful deployment + * - Use GitHub API compare endpoint: GET /repos/{owner}/{repo}/compare/{base}...{head} + * - Only process commits since last deployment + * - Would be more efficient but requires storing deployment state + * + * Current approach prioritizes reliability over speed with: + * - Batch processing with delays between batches + * - Smart early termination when consecutive tickets are already in target status + * - Safety limits to prevent runaway processing + * + * @param {Object} octokit - Octokit instance + * @param {Object} jiraUtil - Jira utility instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} branch - Branch name + * @param {string} targetStatus - Target status to check for (e.g., "Done", "Deployed to Staging") + * @param {number} consecutiveDoneThreshold - Number of consecutive "done" tickets to stop at (default: 5) + * @returns {Promise} Array of unique issue keys that need updating + */ +async function fetchCommitsAndExtractIssues (octokit, jiraUtil, owner, repo, branch, targetStatus, consecutiveDoneThreshold = 5) { + const finishOp = logger.startOperation('fetchCommitsAndExtractIssues', { + owner, + repo, + branch, + targetStatus, + consecutiveDoneThreshold, + }) + + try { + logger.info('Fetching commits with smart iteration (stops at consecutive done tickets)', { + owner, + repo, + branch, + targetStatus, + consecutiveDoneThreshold, + }) + + const allIssueKeys = [] + let page = 1 + let consecutiveDoneCount = 0 + let totalCommitsChecked = 0 + let shouldContinue = true + const perPage = 100 // GitHub API max per page + + while (shouldContinue) { + // Fetch commits page by page + logger.debug('Fetching commits page', { page, perPage }) + + const { data: commits } = await fetchGitHubDataWithRetry( + async () => octokit.rest.repos.listCommits({ + owner, + repo, + sha: branch, + per_page: perPage, + page, + }), + {} + ) + + if (commits.length === 0) { + logger.info('No more commits to fetch') + shouldContinue = false + break + } + + totalCommitsChecked += commits.length + + // Extract issue keys from this batch of commits + const batchIssueKeys = [] + for (const commit of commits) { + const message = commit.commit.message + const matches = message.match(ACTION_CONSTANTS.VALIDATION.ISSUE_KEY_EXTRACT_PATTERN) + + if (matches) { + for (const key of matches) { + if (isValidIssueKey(key) && !batchIssueKeys.includes(key) && !allIssueKeys.includes(key)) { + batchIssueKeys.push(key) + } + } + } + } + + logger.debug('Extracted issues from batch', { + page, + commitsInBatch: commits.length, + issuesInBatch: batchIssueKeys.length, + issues: batchIssueKeys, + }) + + // Check status of extracted issues in Jira + if (batchIssueKeys.length > 0) { + for (let i = 0; i < batchIssueKeys.length; i++) { + const issueKey = batchIssueKeys[i] + + try { + // Fetch issue status from Jira with retry logic + let issueData = null + let retryCount = 0 + const maxRetries = 3 + + while (retryCount < maxRetries) { + try { + const issueResponse = await jiraUtil.request(`/issue/${issueKey}?fields=status`) + issueData = await issueResponse.json() + break // Success, exit retry loop + } catch (apiError) { + retryCount++ + if (retryCount >= maxRetries) { + throw apiError // Re-throw if all retries failed + } + + // Check if it's a transient error (5xx or rate limit) + const isTransient = apiError.statusCode >= 500 || apiError.statusCode === 429 + if (isTransient) { + const delay = 1000 * Math.pow(2, retryCount) // Exponential backoff: 2s, 4s + logger.warn('Transient error, retrying', { + issueKey, + statusCode: apiError.statusCode, + retryCount, + retryAfter: delay, + }) + await new Promise(resolve => setTimeout(resolve, delay)) + } else { + throw apiError // Non-transient error, don't retry + } + } + } + + if (!issueData) { + throw new Error('Failed to fetch issue after retries') + } + + const currentStatus = issueData.fields.status.name + + logger.debug('Checked issue status', { + issueKey, + currentStatus, + targetStatus, + consecutiveCount: consecutiveDoneCount, + }) + + if (currentStatus === targetStatus) { + // Issue is already in target status + consecutiveDoneCount++ + logger.debug('Issue already in target status', { + issueKey, + currentStatus, + consecutiveDoneCount, + }) + + // Stop if we've found enough consecutive done tickets + if (consecutiveDoneCount >= consecutiveDoneThreshold) { + logger.info('Found consecutive tickets already in target status, stopping iteration', { + consecutiveDoneCount, + threshold: consecutiveDoneThreshold, + lastIssue: issueKey, + }) + + finishOp('success', { + commitsChecked: totalCommitsChecked, + issueKeysFound: allIssueKeys.length, + stoppedEarly: true, + consecutiveDone: consecutiveDoneCount, + }) + + return allIssueKeys + } + } else { + // Issue is NOT in target status, needs updating + consecutiveDoneCount = 0 // Reset counter + allIssueKeys.push(issueKey) + logger.debug('Issue needs updating', { + issueKey, + currentStatus, + targetStatus, + }) + } + } catch (error) { + // Distinguish between "not found" and other errors + const isNotFound = error.statusCode === 404 || error.message?.includes('Issue Does Not Exist') + + if (isNotFound) { + logger.warn('Issue not found in Jira, skipping', { + issueKey, + error: error.message, + }) + // Don't add to update list, but also don't break consecutive counter + // (might be a deleted issue or wrong project) + } else { + // Other error (permission, network, etc.) + logger.error('Error checking issue status, skipping', { + issueKey, + error: error.message, + statusCode: error.statusCode, + }) + // Don't reset counter - might be transient issue + } + } + + // Add delay between Jira API calls to respect rate limits (10 req/sec) + // 150ms delay = ~6.6 requests/second (well under limit) + if (i < batchIssueKeys.length - 1) { + await new Promise(resolve => setTimeout(resolve, 150)) + } + } + } + + // Move to next page + page++ + + // Safety limit: stop after 1000 commits (10 pages) + if (totalCommitsChecked >= 1000) { + logger.warn('Reached safety limit of 1000 commits, stopping iteration') + shouldContinue = false + break + } + + // If this batch had fewer commits than requested, we've reached the end + if (commits.length < perPage) { + logger.info('Reached end of commit history') + shouldContinue = false + break + } + + // Add small delay between batches to avoid rate limiting (reliability over speed) + if (shouldContinue) { + logger.debug('Waiting 1 second before next batch to avoid rate limits') + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + + finishOp('success', { + commitsChecked: totalCommitsChecked, + issueKeysFound: allIssueKeys.length, + stoppedEarly: false, + }) + + logger.info('Smart iteration completed', { + commitsChecked: totalCommitsChecked, + issueKeysFound: allIssueKeys.length, + issueKeys: allIssueKeys, + }) + + return allIssueKeys + } catch (error) { + finishOp('error', { error: error.message }) + logger.error('Failed to fetch commits from GitHub API', { + owner, + repo, + branch, + error: error.message, + }) + // Return empty array on error, don't throw + return [] + } +} + // ============================================================================ // JIRA FIELD PREPARATION // ============================================================================ @@ -1231,29 +1485,53 @@ async function handlePushEvent (branch, jiraUtil, githubRepository, githubToken) logger.info('Production deployment detected', { branch }) try { - const commitHistoryIssues = await jiraUtil.getIssueKeysFromCommitHistory( - ACTION_CONSTANTS.COMMIT_HISTORY.PRODUCTION_RANGE, - ACTION_CONSTANTS.COMMIT_HISTORY.HEAD + // Smart iteration: fetch commits and stop when we find 5 consecutive tickets already in "Done" + const commitHistoryIssues = await fetchCommitsAndExtractIssues( + octokit, + jiraUtil, + owner, + repo, + branch, + targetStatus, // "Done" + 5 // Stop after 5 consecutive "Done" tickets ) if (commitHistoryIssues.length > 0) { logger.info('Found issues in production commit history', { issueCount: commitHistoryIssues.length, + issueKeys: commitHistoryIssues, }) - const updateResults = await updateIssuesFromCommitHistory( - jiraUtil, - commitHistoryIssues, - targetStatus, - ACTION_CONSTANTS.EXCLUDED_STATES, - transitionFields, - customFields + // For production: ONLY update custom fields, Jira automation handles status transition + // Setting Production Release Timestamp + Release Environment triggers auto-transition to Done + const preparedCustomFields = prepareCustomFields(customFields) + + logger.info('Updating production custom fields (Jira automation will handle status transition)', { + issueCount: commitHistoryIssues.length, + fields: Object.keys(preparedCustomFields), + }) + + const results = await Promise.allSettled( + commitHistoryIssues.map((issueKey) => + jiraUtil.updateCustomFields(issueKey, preparedCustomFields) + ) ) + const successful = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected') + logger.info('Production deployment completed', { - successful: updateResults.successful, - failed: updateResults.failed, + successful, + failed: failed.length, + issueKeys: commitHistoryIssues, }) + + if (failed.length > 0) { + logger.warn('Some production updates failed', { + failedCount: failed.length, + errors: failed.map(r => r.reason?.message).slice(0, 5), + }) + } } else { logger.info('No Jira issues found in production commit history') } @@ -1269,13 +1547,47 @@ async function handlePushEvent (branch, jiraUtil, githubRepository, githubToken) logger.info('Processing direct PR merge to production', { prUrl }) try { - await updateIssuesByPR( - jiraUtil, - prUrl, - targetStatus, - transitionFields, - customFields - ) + // Search for issues mentioning this PR + const jql = `text ~ "${prUrl}"` + const response = await jiraUtil.request('/search', { + method: 'POST', + body: JSON.stringify({ + jql, + fields: [ 'key', 'summary', 'status' ], + maxResults: ACTION_CONSTANTS.GITHUB_API.MAX_RESULTS, + }), + }) + + const data = await response.json() + const issues = data.issues || [] + + if (issues.length > 0) { + logger.info('Found issues for PR in production', { + prUrl, + issueCount: issues.length, + issueKeys: issues.map(i => i.key), + }) + + // For production: ONLY update custom fields + const preparedCustomFields = prepareCustomFields(customFields) + + const results = await Promise.allSettled( + issues.map((issue) => + jiraUtil.updateCustomFields(issue.key, preparedCustomFields) + ) + ) + + const successful = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected') + + logger.info('Production PR updates completed', { + prUrl, + successful, + failed: failed.length, + }) + } else { + logger.info('No issues found for PR', { prUrl }) + } } catch (error) { logger.error('Error updating issues from PR to production', { prUrl, @@ -1293,8 +1605,15 @@ async function handlePushEvent (branch, jiraUtil, githubRepository, githubToken) logger.info('Staging deployment detected', { branch }) try { - const commitHistoryIssues = await jiraUtil.extractIssueKeysFromGitHubContext( - github.context + // Smart iteration: fetch commits and stop when we find 5 consecutive tickets already in "Deployed to Staging" + const commitHistoryIssues = await fetchCommitsAndExtractIssues( + octokit, + jiraUtil, + owner, + repo, + branch, + targetStatus, // "Deployed to Staging" + 5 // Stop after 5 consecutive "Deployed to Staging" tickets ) if (commitHistoryIssues.length > 0) {