Skip to content

Commit 154c131

Browse files
committed
chore: Adding bug fix release notification workflow
1 parent a4f808a commit 154c131

4 files changed

Lines changed: 555 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Notifies referenced GitHub issues when a fix lands in a published release.
3+
*
4+
* After a release transitions to `released`, this script walks every commit
5+
* between the previous non-prerelease tag and the current tag, finds the
6+
* merged PRs that introduced those commits, parses issue-closing keywords
7+
* from each PR body, and comments on each referenced issue with a link to
8+
* the release.
9+
*
10+
* @param {Object} params
11+
* @param {Object} params.github - GitHub API client (Octokit)
12+
* @param {Object} params.context - GitHub Actions context
13+
* @param {Object} params.core - GitHub Actions core utilities
14+
* @param {string} params.tagName - The current release tag (e.g. "v0.96.0")
15+
* @param {string} params.previousTagName - The previous non-prerelease tag
16+
*/
17+
const { parseIssueReferences } = require("./tip-build-notify-issues");
18+
19+
const MARKER_PREFIX = "release-notify:";
20+
21+
module.exports = async ({
22+
github,
23+
context,
24+
core,
25+
tagName,
26+
previousTagName,
27+
}) => {
28+
const owner = context.repo.owner;
29+
const repo = context.repo.repo;
30+
31+
if (!tagName) {
32+
core.setFailed("tagName is required.");
33+
return;
34+
}
35+
if (!previousTagName) {
36+
core.setFailed("previousTagName is required.");
37+
return;
38+
}
39+
40+
try {
41+
const commits = await listCommitsInRange({
42+
github,
43+
owner,
44+
repo,
45+
base: previousTagName,
46+
head: tagName,
47+
});
48+
if (commits.length === 0) {
49+
core.info(
50+
`No commits between ${previousTagName} and ${tagName}. Skipping.`,
51+
);
52+
return;
53+
}
54+
55+
const mergedPRs = await collectMergedPRs({ github, owner, repo, commits });
56+
if (mergedPRs.length === 0) {
57+
core.info(
58+
`No merged PRs found between ${previousTagName} and ${tagName}.`,
59+
);
60+
return;
61+
}
62+
63+
const issueToPRs = mapIssuesToPRs({ mergedPRs, owner, repo, core });
64+
if (issueToPRs.size === 0) {
65+
core.info("No issue-closing keywords found in PR bodies. Skipping.");
66+
return;
67+
}
68+
69+
core.info(
70+
`Found ${issueToPRs.size} issue(s) closed by PRs in ${tagName}.`,
71+
);
72+
73+
let commented = 0;
74+
for (const [issueNumber, prNumbers] of issueToPRs) {
75+
const alreadyCommented = await hasExistingComment({
76+
github,
77+
owner,
78+
repo,
79+
issueNumber,
80+
tagName,
81+
});
82+
if (alreadyCommented) {
83+
core.info(
84+
`Issue #${issueNumber} already has a release comment for ${tagName}. Skipping.`,
85+
);
86+
continue;
87+
}
88+
89+
const body = buildComment({ owner, repo, tagName, prNumbers });
90+
91+
try {
92+
await github.rest.issues.createComment({
93+
owner,
94+
repo,
95+
issue_number: issueNumber,
96+
body,
97+
});
98+
core.info(`Commented on issue #${issueNumber}.`);
99+
commented++;
100+
} catch (err) {
101+
core.warning(
102+
`Failed to comment on issue #${issueNumber}: ${err.message}`,
103+
);
104+
}
105+
}
106+
107+
core.info(`Notified ${commented} issue(s) about release ${tagName}.`);
108+
} catch (error) {
109+
core.setFailed(`Failed to notify issues: ${error.message}`);
110+
}
111+
};
112+
113+
async function listCommitsInRange({ github, owner, repo, base, head }) {
114+
return await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
115+
owner,
116+
repo,
117+
basehead: `${base}...${head}`,
118+
per_page: 100,
119+
});
120+
}
121+
122+
async function collectMergedPRs({ github, owner, repo, commits }) {
123+
const seen = new Map();
124+
for (const commit of commits) {
125+
const { data: prs } =
126+
await github.rest.repos.listPullRequestsAssociatedWithCommit({
127+
owner,
128+
repo,
129+
commit_sha: commit.sha,
130+
});
131+
for (const pr of prs) {
132+
if (pr.merged_at !== null && !seen.has(pr.number)) {
133+
seen.set(pr.number, pr);
134+
}
135+
}
136+
}
137+
return [...seen.values()];
138+
}
139+
140+
function mapIssuesToPRs({ mergedPRs, owner, repo, core }) {
141+
const map = new Map();
142+
for (const pr of mergedPRs) {
143+
if (!pr.body) {
144+
continue;
145+
}
146+
const refs = parseIssueReferences(pr.body, owner, repo);
147+
for (const ref of refs) {
148+
if (ref.owner !== owner || ref.repo !== repo) {
149+
if (core) {
150+
core.warning(
151+
`Skipping cross-repo issue reference: ${ref.owner}/${ref.repo}#${ref.number} (from PR #${pr.number})`,
152+
);
153+
}
154+
continue;
155+
}
156+
const list = map.get(ref.number) || [];
157+
if (!list.includes(pr.number)) {
158+
list.push(pr.number);
159+
}
160+
map.set(ref.number, list);
161+
}
162+
}
163+
return map;
164+
}
165+
166+
async function hasExistingComment({
167+
github,
168+
owner,
169+
repo,
170+
issueNumber,
171+
tagName,
172+
}) {
173+
const comments = await github.paginate(github.rest.issues.listComments, {
174+
owner,
175+
repo,
176+
issue_number: issueNumber,
177+
per_page: 100,
178+
});
179+
180+
return comments.some(
181+
(c) => c.body && c.body.includes(`${MARKER_PREFIX} ${tagName}`),
182+
);
183+
}
184+
185+
function buildComment({ owner, repo, tagName, prNumbers }) {
186+
const prLinks = prNumbers.map((n) => `#${n}`).join(", ");
187+
const releaseUrl = `https://github.com/${owner}/${repo}/releases/tag/${tagName}`;
188+
189+
return [
190+
`A fix for this issue has been released in [\`${tagName}\`](${releaseUrl}), shipped via ${prLinks}.`,
191+
"",
192+
"You can install Terragrunt by following [these instructions](https://docs.terragrunt.com/getting-started/install/).",
193+
"",
194+
`<!-- ${MARKER_PREFIX} ${tagName} -->`,
195+
].join("\n");
196+
}
197+
198+
module.exports.parseIssueReferences = parseIssueReferences;
199+
module.exports.mapIssuesToPRs = mapIssuesToPRs;
200+
module.exports.buildComment = buildComment;
201+
module.exports.hasExistingComment = hasExistingComment;
202+
module.exports.collectMergedPRs = collectMergedPRs;
203+
module.exports.listCommitsInRange = listCommitsInRange;
204+
module.exports.MARKER_PREFIX = MARKER_PREFIX;

0 commit comments

Comments
 (0)