Skip to content

Commit af65256

Browse files
ci: add GitHub Action to create Linear tickets from changelog
Automatically creates Linear tickets for new features when changelog is updated and merged to main. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent c8032d4 commit af65256

File tree

1 file changed

+147
-0
lines changed

1 file changed

+147
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: Create Linear Tickets from Changelog
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'docs/changelog/*.mdx'
8+
9+
jobs:
10+
create-tickets:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout code
14+
uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 2
17+
18+
- name: Get changed changelog files
19+
id: changes
20+
run: |
21+
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD | grep 'docs/changelog/' || echo "")
22+
echo "Changed files: $CHANGED_FILES"
23+
if [ -n "$CHANGED_FILES" ]; then
24+
FIRST_FILE=$(echo "$CHANGED_FILES" | head -n 1)
25+
echo "file=$FIRST_FILE" >> $GITHUB_OUTPUT
26+
fi
27+
28+
- name: Setup Node.js
29+
if: steps.changes.outputs.file != ''
30+
uses: actions/setup-node@v4
31+
with:
32+
node-version: '20'
33+
34+
- name: Create Linear tickets
35+
if: steps.changes.outputs.file != ''
36+
env:
37+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
38+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
39+
LINEAR_ASSIGNEE_ID: ${{ secrets.LINEAR_ASSIGNEE_ID }}
40+
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
41+
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
42+
CHANGELOG_FILE: ${{ steps.changes.outputs.file }}
43+
run: |
44+
if [ -z "${LINEAR_API_KEY}" ]; then
45+
echo "Linear API key not provided. Skipping Linear ticket creation."
46+
exit 0
47+
fi
48+
49+
node << 'EOF'
50+
const fs = require('fs');
51+
const https = require('https');
52+
53+
const { LINEAR_API_KEY, LINEAR_TEAM_ID, LINEAR_ASSIGNEE_ID, LINEAR_PROJECT_ID, LINEAR_LABEL_ID, CHANGELOG_FILE } = process.env;
54+
55+
function parseChangelog(filePath) {
56+
const content = fs.readFileSync(filePath, 'utf-8');
57+
const updateMatch = content.match(/<Update\s+label="([^"]+)"[^>]*>([\s\S]*?)<\/Update>/);
58+
if (!updateMatch) return null;
59+
60+
const [, date, body] = updateMatch;
61+
const versionMatch = body.match(/`([^`]+)`/);
62+
const version = versionMatch ? versionMatch[1] : '';
63+
64+
const newFeaturesMatch = body.match(/## New features\s*([\s\S]*?)(?=##|\s*$)/);
65+
const improvementsMatch = body.match(/## Improvements\s*([\s\S]*?)(?=##|\s*$)/);
66+
const featuresSection = newFeaturesMatch?.[1] || improvementsMatch?.[1] || '';
67+
68+
const features = [];
69+
for (const line of featuresSection.split('\n')) {
70+
const trimmed = line.trim();
71+
if (trimmed.startsWith('* **')) {
72+
const match = trimmed.match(/\*\s+\*\*([^*]+)\*\*\s*[-:]?\s*(.*)/);
73+
if (match) features.push({ title: match[1].trim(), description: match[2].trim() });
74+
}
75+
}
76+
return { date, version, features };
77+
}
78+
79+
function makeLinearRequest(query, variables) {
80+
return new Promise((resolve, reject) => {
81+
const data = JSON.stringify({ query, variables });
82+
const req = https.request({
83+
hostname: 'api.linear.app',
84+
port: 443,
85+
path: '/graphql',
86+
method: 'POST',
87+
headers: {
88+
'Content-Type': 'application/json',
89+
'Authorization': LINEAR_API_KEY,
90+
'Content-Length': Buffer.byteLength(data)
91+
}
92+
}, (res) => {
93+
let responseData = '';
94+
res.on('data', chunk => responseData += chunk);
95+
res.on('end', () => {
96+
const parsed = JSON.parse(responseData);
97+
parsed.errors ? reject(new Error(JSON.stringify(parsed.errors))) : resolve(parsed.data);
98+
});
99+
});
100+
req.on('error', reject);
101+
req.write(data);
102+
req.end();
103+
});
104+
}
105+
106+
async function createIssue(feature, version, date) {
107+
const dueDate = new Date();
108+
dueDate.setDate(dueDate.getDate() + 7);
109+
110+
const input = {
111+
teamId: LINEAR_TEAM_ID,
112+
title: `[${version}] ${feature.title}`,
113+
description: `**From changelog (${date})**\n\n${feature.description || 'No additional description.'}\n\n---\n*Auto-created from changelog*`,
114+
dueDate: dueDate.toISOString().split('T')[0],
115+
};
116+
if (LINEAR_ASSIGNEE_ID) input.assigneeId = LINEAR_ASSIGNEE_ID;
117+
if (LINEAR_PROJECT_ID) input.projectId = LINEAR_PROJECT_ID;
118+
if (LINEAR_LABEL_ID) input.labelIds = [LINEAR_LABEL_ID];
119+
120+
const mutation = `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }`;
121+
return makeLinearRequest(mutation, { input });
122+
}
123+
124+
async function main() {
125+
if (!LINEAR_TEAM_ID) { console.error('LINEAR_TEAM_ID required'); process.exit(1); }
126+
127+
const changelog = parseChangelog(CHANGELOG_FILE);
128+
if (!changelog || changelog.features.length === 0) {
129+
console.log('No features found');
130+
process.exit(0);
131+
}
132+
133+
console.log(`Found ${changelog.features.length} features in ${changelog.version} (${changelog.date})`);
134+
135+
for (const feature of changelog.features) {
136+
try {
137+
const result = await createIssue(feature, changelog.version, changelog.date);
138+
if (result.issueCreate?.success) {
139+
console.log(`Created: ${result.issueCreate.issue.identifier} - ${result.issueCreate.issue.url}`);
140+
}
141+
} catch (error) {
142+
console.error(`Error creating ticket for ${feature.title}: ${error.message}`);
143+
}
144+
}
145+
}
146+
main();
147+
EOF

0 commit comments

Comments
 (0)