Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/jira-close-on-merge.yml
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
159 changes: 159 additions & 0 deletions .github/workflows/jira-sync.yml
Original file line number Diff line number Diff line change
@@ -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}`);
Loading