diff --git a/.github/workflows/jira-close-on-merge.yml b/.github/workflows/jira-close-on-merge.yml new file mode 100644 index 000000000..8e7cfff14 --- /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/jql`, { + 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 000000000..913ed279b --- /dev/null +++ b/.github/workflows/jira-sync.yml @@ -0,0 +1,204 @@ +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 }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_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/jql`, { + 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; + const createdTickets = []; // { key, ghNumber, title, type } + const failures = []; // { ghNumber, error } + + 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 GitHub labels: bug -> Bug, feature/enhancement -> Story, + // anything else -> Task. (Unstructured sources like Slack will use the shared + // triage service for LLM classification; GitHub issues are reliably labelled.) + const labels = issue.labels.map(l => l.name.toLowerCase()); + const issuetype = + labels.includes('bug') ? 'Bug' : + (labels.includes('feature') || labels.includes('enhancement')) ? 'Story' : + '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++; + createdTickets.push({ key: data.key, ghNumber: issue.number, title, type: issuetype }); + } else { + const err = await createRes.text(); + console.error(`Failed to create ticket for GH #${issue.number}: ${err}`); + failures.push({ ghNumber: issue.number, error: err.substring(0, 200) }); + } + } + + console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`); + + // Post a Slack summary โ€” only when something was created or failed + // (no-news-is-good-news: stay silent on quiet days). Reuses the same + // SLACK_WEBHOOK_URL the app-play-store-tracker posts with. + const slackUrl = process.env.SLACK_WEBHOOK_URL; + if (slackUrl && (createdTickets.length > 0 || failures.length > 0)) { + const repo = `${context.repo.owner}/${context.repo.repo}`; + const date = new Date().toISOString().slice(0, 10); + const lines = createdTickets.map(t => + `โ€ข \`${t.key}\` โ† GH #${t.ghNumber} โ€” ${t.title} _(${t.type})_` + ).join('\\n'); + + const blocks = [ + { type: 'header', text: { type: 'plain_text', text: `๐Ÿ”„ GitHub โ†’ Jira Sync โ€” ${date}` } }, + { type: 'section', text: { type: 'mrkdwn', + text: `*${repo}*\\nCreated *${created}* ticket(s) ยท Skipped ${skipped}` } }, + ]; + if (createdTickets.length > 0) { + blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*New tickets*\\n${lines}` } }); + } + if (failures.length > 0) { + const fl = failures.map(f => `โ€ข GH #${f.ghNumber}: ${f.error}`).join('\\n'); + blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*โš ๏ธ Failed (${failures.length})*\\n${fl}` } }); + } + + const slackRes = await fetch(slackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blocks }), + }); + console.log(slackRes.ok ? 'Posted summary to Slack' : `Slack post failed: ${await slackRes.text()}`); + } else if (!slackUrl) { + console.log('SLACK_WEBHOOK_URL not set โ€” skipping Slack summary'); + } else { + console.log('Nothing created/failed โ€” no Slack post (quiet day)'); + }