Skip to content

Commit 6fd5b69

Browse files
Linear issue release tagging (#917)
* Add workflow to automatically tag Linear issues with release versions This adds a new GitHub Actions workflow that runs after a successful production release and automatically tags linked Linear issues with the release version label. Components: - scripts/tagLinearIssuesWithRelease.mjs: Script that parses the changelog to find PR numbers, queries Linear API for issues linked via GitHub attachments, and adds a version label to those issues - .github/workflows/tag-linear-issues.yml: Workflow that triggers after the release workflow completes and runs the tagging script Required secrets: - LINEAR_API_KEY: Linear API key with write access - LINEAR_TEAM_ID: Linear team identifier (e.g., 'SOU') Closes SOU-535 Co-authored-by: Michael Sukkarieh <msukkari@users.noreply.github.com> * Add changelog entry for Linear issue release tagging workflow Co-authored-by: Michael Sukkarieh <msukkari@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Michael Sukkarieh <msukkari@users.noreply.github.com>
1 parent 306a29e commit 6fd5b69

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Tag Linear Issues with Release
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Release Sourcebot (Production)"]
6+
types:
7+
- completed
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
tag-linear-issues:
14+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout repository
18+
uses: actions/checkout@v4
19+
with:
20+
ref: main
21+
fetch-depth: 0
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: "24"
27+
28+
- name: Extract version from release
29+
id: extract_version
30+
run: |
31+
# Get the latest version from CHANGELOG.md (first non-Unreleased version)
32+
VERSION=$(grep -oP '## \[\K[0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md | head -n 1)
33+
34+
if [ -z "$VERSION" ]; then
35+
echo "Error: Could not extract version from CHANGELOG.md"
36+
exit 1
37+
fi
38+
39+
echo "Detected version: $VERSION"
40+
echo "version=$VERSION" >> $GITHUB_OUTPUT
41+
42+
- name: Tag Linear issues
43+
env:
44+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
45+
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
46+
run: |
47+
node scripts/tagLinearIssuesWithRelease.mjs "${{ steps.extract_version.outputs.version }}"

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added GitHub workflow to automatically tag Linear issues with the release version when a new release is published. [#917](https://github.com/sourcebot-dev/sourcebot/pull/917)
12+
1013
## [4.11.5] - 2026-02-21
1114

1215
### Fixed
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* This script automatically tags Linear issues with the release version
5+
* when a new release is published.
6+
*
7+
* It works by:
8+
* 1. Parsing the CHANGELOG.md to find PR numbers for a specific version
9+
* 2. Using the Linear API to find issues that have GitHub PR attachments
10+
* 3. Creating a label for the release version if it doesn't exist
11+
* 4. Adding the release label to those issues
12+
*
13+
* Environment variables required:
14+
* - LINEAR_API_KEY: Linear API key with write access
15+
* - LINEAR_TEAM_ID: Linear team ID (e.g., "SOU")
16+
*
17+
* Usage:
18+
* node scripts/tagLinearIssuesWithRelease.mjs <version>
19+
* Example: node scripts/tagLinearIssuesWithRelease.mjs 4.11.4
20+
*/
21+
22+
import * as fs from "fs";
23+
import * as path from "path";
24+
25+
const LINEAR_API_URL = "https://api.linear.app/graphql";
26+
const GITHUB_REPO = "sourcebot-dev/sourcebot";
27+
28+
async function linearGraphQL(query, variables = {}) {
29+
const apiKey = process.env.LINEAR_API_KEY;
30+
if (!apiKey) {
31+
throw new Error("LINEAR_API_KEY environment variable is required");
32+
}
33+
34+
const response = await fetch(LINEAR_API_URL, {
35+
method: "POST",
36+
headers: {
37+
"Content-Type": "application/json",
38+
Authorization: apiKey,
39+
},
40+
body: JSON.stringify({ query, variables }),
41+
});
42+
43+
const result = await response.json();
44+
45+
if (result.errors) {
46+
throw new Error(`Linear API error: ${JSON.stringify(result.errors)}`);
47+
}
48+
49+
return result.data;
50+
}
51+
52+
/**
53+
* Parse the changelog to extract PR numbers for a specific version
54+
*/
55+
function getPRsForVersion(changelogPath, version) {
56+
const changelog = fs.readFileSync(changelogPath, "utf-8");
57+
const lines = changelog.split("\n");
58+
59+
const prNumbers = [];
60+
let inTargetVersion = false;
61+
62+
for (const line of lines) {
63+
// Check if we're entering the target version section
64+
const versionMatch = line.match(/^## \[([^\]]+)\]/);
65+
if (versionMatch) {
66+
if (versionMatch[1] === version) {
67+
inTargetVersion = true;
68+
continue;
69+
} else if (inTargetVersion) {
70+
// We've moved past the target version, stop parsing
71+
break;
72+
}
73+
}
74+
75+
// If we're in the target version section, extract PR numbers
76+
if (inTargetVersion) {
77+
const prMatches = line.matchAll(/\[#(\d+)\]\([^)]+\)/g);
78+
for (const match of prMatches) {
79+
prNumbers.push(parseInt(match[1], 10));
80+
}
81+
}
82+
}
83+
84+
return [...new Set(prNumbers)]; // Remove duplicates
85+
}
86+
87+
/**
88+
* Find Linear issues that have attachments linking to the given GitHub PRs
89+
*/
90+
async function findLinearIssuesForPRs(prNumbers) {
91+
const issues = [];
92+
93+
for (const prNumber of prNumbers) {
94+
const prUrl = `https://github.com/${GITHUB_REPO}/pull/${prNumber}`;
95+
96+
// Query Linear for attachments that match this PR URL
97+
const data = await linearGraphQL(
98+
`
99+
query($url: String!) {
100+
attachmentsForURL(url: $url) {
101+
nodes {
102+
id
103+
url
104+
issue {
105+
id
106+
identifier
107+
title
108+
labels {
109+
nodes {
110+
id
111+
name
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
118+
`,
119+
{ url: prUrl }
120+
);
121+
122+
if (data.attachmentsForURL?.nodes) {
123+
for (const attachment of data.attachmentsForURL.nodes) {
124+
if (attachment.issue) {
125+
issues.push({
126+
issueId: attachment.issue.id,
127+
identifier: attachment.issue.identifier,
128+
title: attachment.issue.title,
129+
existingLabels: attachment.issue.labels?.nodes || [],
130+
prNumber,
131+
});
132+
}
133+
}
134+
}
135+
}
136+
137+
// Remove duplicate issues (same issue might be linked to multiple PRs)
138+
const uniqueIssues = [];
139+
const seenIds = new Set();
140+
for (const issue of issues) {
141+
if (!seenIds.has(issue.issueId)) {
142+
seenIds.add(issue.issueId);
143+
uniqueIssues.push(issue);
144+
}
145+
}
146+
147+
return uniqueIssues;
148+
}
149+
150+
/**
151+
* Get the team ID from the team key
152+
*/
153+
async function getTeamId(teamKey) {
154+
const data = await linearGraphQL(
155+
`
156+
query($key: String!) {
157+
team(id: $key) {
158+
id
159+
name
160+
}
161+
}
162+
`,
163+
{ key: teamKey }
164+
);
165+
166+
if (!data.team) {
167+
throw new Error(`Team with key "${teamKey}" not found`);
168+
}
169+
170+
return data.team.id;
171+
}
172+
173+
/**
174+
* Find or create a label for the release version
175+
*/
176+
async function findOrCreateReleaseLabel(teamId, version) {
177+
const labelName = `v${version}`;
178+
179+
// First, search for existing label
180+
const searchData = await linearGraphQL(
181+
`
182+
query($teamId: String!) {
183+
team(id: $teamId) {
184+
labels {
185+
nodes {
186+
id
187+
name
188+
}
189+
}
190+
}
191+
}
192+
`,
193+
{ teamId }
194+
);
195+
196+
const existingLabel = searchData.team?.labels?.nodes?.find(
197+
(label) => label.name === labelName
198+
);
199+
200+
if (existingLabel) {
201+
console.log(`Found existing label: ${labelName}`);
202+
return existingLabel.id;
203+
}
204+
205+
// Create the label if it doesn't exist
206+
console.log(`Creating new label: ${labelName}`);
207+
const createData = await linearGraphQL(
208+
`
209+
mutation($teamId: String!, $name: String!) {
210+
issueLabelCreate(input: { teamId: $teamId, name: $name, color: "#10B981" }) {
211+
issueLabel {
212+
id
213+
name
214+
}
215+
success
216+
}
217+
}
218+
`,
219+
{ teamId, name: labelName }
220+
);
221+
222+
if (!createData.issueLabelCreate?.success) {
223+
throw new Error(`Failed to create label: ${labelName}`);
224+
}
225+
226+
return createData.issueLabelCreate.issueLabel.id;
227+
}
228+
229+
/**
230+
* Add a label to an issue
231+
*/
232+
async function addLabelToIssue(issueId, labelId, existingLabelIds) {
233+
// Combine existing labels with the new one
234+
const allLabelIds = [...new Set([...existingLabelIds, labelId])];
235+
236+
const data = await linearGraphQL(
237+
`
238+
mutation($issueId: String!, $labelIds: [String!]!) {
239+
issueUpdate(id: $issueId, input: { labelIds: $labelIds }) {
240+
success
241+
issue {
242+
identifier
243+
}
244+
}
245+
}
246+
`,
247+
{ issueId, labelIds: allLabelIds }
248+
);
249+
250+
return data.issueUpdate?.success;
251+
}
252+
253+
async function main() {
254+
const version = process.argv[2];
255+
256+
if (!version) {
257+
console.error("Usage: node tagLinearIssuesWithRelease.mjs <version>");
258+
console.error("Example: node tagLinearIssuesWithRelease.mjs 4.11.4");
259+
process.exit(1);
260+
}
261+
262+
const teamKey = process.env.LINEAR_TEAM_ID;
263+
if (!teamKey) {
264+
console.error("LINEAR_TEAM_ID environment variable is required");
265+
process.exit(1);
266+
}
267+
268+
console.log(`Tagging Linear issues for release v${version}`);
269+
270+
// Find the changelog file
271+
const changelogPath = path.join(process.cwd(), "CHANGELOG.md");
272+
if (!fs.existsSync(changelogPath)) {
273+
console.error(`Changelog not found at: ${changelogPath}`);
274+
process.exit(1);
275+
}
276+
277+
// Step 1: Parse changelog for PR numbers
278+
console.log("\n1. Parsing changelog for PR numbers...");
279+
const prNumbers = getPRsForVersion(changelogPath, version);
280+
if (prNumbers.length === 0) {
281+
console.log(`No PRs found for version ${version}`);
282+
process.exit(0);
283+
}
284+
console.log(` Found ${prNumbers.length} PRs: ${prNumbers.join(", ")}`);
285+
286+
// Step 2: Find Linear issues for these PRs
287+
console.log("\n2. Finding Linear issues linked to these PRs...");
288+
const issues = await findLinearIssuesForPRs(prNumbers);
289+
if (issues.length === 0) {
290+
console.log(" No Linear issues found linked to these PRs");
291+
process.exit(0);
292+
}
293+
console.log(` Found ${issues.length} Linear issues:`);
294+
for (const issue of issues) {
295+
console.log(` - ${issue.identifier}: ${issue.title} (PR #${issue.prNumber})`);
296+
}
297+
298+
// Step 3: Get team ID and find/create release label
299+
console.log("\n3. Finding or creating release label...");
300+
const teamId = await getTeamId(teamKey);
301+
const labelId = await findOrCreateReleaseLabel(teamId, version);
302+
303+
// Step 4: Add label to all issues
304+
console.log("\n4. Adding release label to issues...");
305+
let successCount = 0;
306+
for (const issue of issues) {
307+
const existingLabelIds = issue.existingLabels.map((l) => l.id);
308+
309+
// Check if issue already has the label
310+
if (issue.existingLabels.some((l) => l.name === `v${version}`)) {
311+
console.log(` ${issue.identifier}: Already has label v${version}, skipping`);
312+
successCount++;
313+
continue;
314+
}
315+
316+
const success = await addLabelToIssue(issue.issueId, labelId, existingLabelIds);
317+
if (success) {
318+
console.log(` ${issue.identifier}: Added label v${version}`);
319+
successCount++;
320+
} else {
321+
console.error(` ${issue.identifier}: Failed to add label`);
322+
}
323+
}
324+
325+
console.log(`\nDone! Tagged ${successCount}/${issues.length} issues with v${version}`);
326+
}
327+
328+
main().catch((error) => {
329+
console.error("Error:", error.message);
330+
process.exit(1);
331+
});

0 commit comments

Comments
 (0)