diff --git a/.github/workflows/jira-close-on-merge.yml b/.github/workflows/jira-close-on-merge.yml new file mode 100644 index 00000000..e18ce655 --- /dev/null +++ b/.github/workflows/jira-close-on-merge.yml @@ -0,0 +1,131 @@ +name: Close Jira ticket on PR merge + +on: + pull_request: + types: [closed] + +jobs: + close-jira: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close matching Jira tickets + uses: actions/github-script@v7 + env: + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + with: + script: | + const jiraEmail = process.env.JIRA_EMAIL; + const jiraToken = process.env.JIRA_API_TOKEN; + const jiraBase = process.env.JIRA_BASE_URL; + const jiraAuth = Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64'); + + const pr = context.payload.pull_request; + const prBody = pr.body || ''; + const prTitle = pr.title || ''; + const combined = `${prTitle} ${prBody}`; + + // Extract issue numbers from "fixes #123", "closes #123", "resolves #123" + const pattern = /(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s+#(\d+)/gi; + const issueNumbers = new Set(); + let match; + while ((match = pattern.exec(combined)) !== null) { + issueNumbers.add(parseInt(match[1])); + } + + if (issueNumbers.size === 0) { + console.log('No issue references found in PR title/body. Nothing to close.'); + return; + } + + console.log(`Found issue references: ${[...issueNumbers].map(n => `#${n}`).join(', ')}`); + + // Transition ID for "Done" (moves to "In Production") + const DONE_TRANSITION_ID = '31'; + + for (const ghNum of issueNumbers) { + // Search for matching Jira ticket + const searchRes = await fetch(`${jiraBase}/rest/api/3/search`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${jiraAuth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jql: `project = SCRUM AND summary ~ "GH #${ghNum}"`, + maxResults: 1, + fields: ['summary', 'status'], + }), + }); + const searchData = await searchRes.json(); + + if (!searchData.issues || searchData.issues.length === 0) { + console.log(`No Jira ticket found for GH #${ghNum}`); + continue; + } + + const jiraIssue = searchData.issues[0]; + const jiraKey = jiraIssue.key; + const currentStatus = jiraIssue.fields.status.name; + + // Skip if already in production + if (currentStatus === 'In Production.') { + console.log(`${jiraKey} already in production, skipping`); + continue; + } + + // Transition to Done + const transRes = await fetch(`${jiraBase}/rest/api/3/issue/${jiraKey}/transitions`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${jiraAuth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + transition: { id: DONE_TRANSITION_ID }, + }), + }); + + if (transRes.ok) { + // Add comment + await fetch(`${jiraBase}/rest/api/3/issue/${jiraKey}/comment`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${jiraAuth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + body: { + version: 1, + type: 'doc', + content: [{ + type: 'paragraph', + content: [{ + type: 'text', + text: `Closed via PR #${pr.number}: ${prTitle} (merged by @${pr.merged_by.login})`, + }], + }], + }, + }), + }); + console.log(`${jiraKey} transitioned to Done (GH #${ghNum})`); + } else { + const err = await transRes.text(); + console.error(`Failed to transition ${jiraKey}: ${err}`); + } + + // Close GitHub issue too (in case "fixes" keyword didn't auto-close) + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ghNum, + state: 'closed', + }); + console.log(`Closed GitHub issue #${ghNum}`); + } catch (e) { + console.log(`GitHub issue #${ghNum} may already be closed: ${e.message}`); + } + } diff --git a/.github/workflows/jira-sync.yml b/.github/workflows/jira-sync.yml new file mode 100644 index 00000000..c7b43bc1 --- /dev/null +++ b/.github/workflows/jira-sync.yml @@ -0,0 +1,159 @@ +name: Sync GitHub Issues to Jira + +on: + schedule: + # 9 AM IST = 3:30 AM UTC + - cron: '30 3 * * *' + workflow_dispatch: # Allow manual trigger + +jobs: + sync-issues: + runs-on: ubuntu-latest + steps: + - name: Sync new GitHub issues to Jira + uses: actions/github-script@v7 + env: + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + with: + script: | + const jiraEmail = process.env.JIRA_EMAIL; + const jiraToken = process.env.JIRA_API_TOKEN; + const jiraBase = process.env.JIRA_BASE_URL; + const jiraAuth = Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64'); + const jiraProject = 'SCRUM'; + + // Fetch open GitHub issues (not PRs) + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const ghIssues = issues.filter(i => !i.pull_request); + console.log(`Found ${ghIssues.length} open GitHub issues`); + + // Fetch existing Jira tickets to check for duplicates + // Search for tickets with "GH #" in summary + const searchRes = await fetch(`${jiraBase}/rest/api/3/search`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${jiraAuth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jql: `project = ${jiraProject} AND summary ~ "GH #"`, + maxResults: 500, + fields: ['summary'], + }), + }); + const searchData = await searchRes.json(); + const existingTickets = (searchData.issues || []).map(i => i.fields.summary); + + // Extract GH numbers from existing tickets + const existingGhNumbers = new Set(); + for (const summary of existingTickets) { + const match = summary.match(/GH #(\d+)/); + if (match) existingGhNumbers.add(parseInt(match[1])); + } + console.log(`Found ${existingGhNumbers.size} existing Jira tickets with GH references`); + + // Also check old-style tickets that have GH number in description + // by checking individual issue numbers + let created = 0; + let skipped = 0; + + for (const issue of ghIssues) { + // Skip if already in Jira + if (existingGhNumbers.has(issue.number)) { + skipped++; + continue; + } + + // Skip issues with empty titles + const title = issue.title.trim(); + if (!title || title === '[Feature]' || title === '[Bug]') { + console.log(`Skipping #${issue.number} - empty/vague title`); + skipped++; + continue; + } + + // Determine type from labels + const labels = issue.labels.map(l => l.name); + const isBug = labels.includes('bug'); + const issuetype = isBug ? 'Bug' : 'Task'; + + // Build description from issue body + const body = (issue.body || '').substring(0, 500).replace(/["\\\n\r\t]/g, ' ').trim(); + const problem = body || `${title} - reported by GitHub user @${issue.user.login}`; + + // Default scoring - Value: 2, Effort: 2, Priority: 1.0 + const scoring = 'Value: 2/3 | Effort: 2/3 | Priority: 1.0 (auto-scored, adjust manually)'; + + // Create Jira ticket + const createRes = await fetch(`${jiraBase}/rest/api/3/issue`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${jiraAuth}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: { + project: { key: jiraProject }, + summary: `${title} (GH #${issue.number})`, + issuetype: { name: issuetype }, + description: { + version: 1, + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Problem' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: problem }], + }, + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Scoring' }], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: scoring }], + }, + { + type: 'heading', + attrs: { level: 3 }, + content: [{ type: 'text', text: 'Reference' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: `GitHub: https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issue.number}`, + }, + ], + }, + ], + }, + }, + }), + }); + + if (createRes.ok) { + const data = await createRes.json(); + console.log(`Created ${data.key} for GH #${issue.number}: ${title}`); + created++; + } else { + const err = await createRes.text(); + console.error(`Failed to create ticket for GH #${issue.number}: ${err}`); + } + } + + console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`);