Skip to content

Commit 3714d60

Browse files
authored
ci: Automatically notify issues after release (#13808)
This ensures that whenever we publish a release, we automatically add a comment to all issues linked to PRs like this: #13312 (comment) ![image](https://github.com/user-attachments/assets/c84a810f-5653-4fcc-85d0-bc6ad0aa4801) This way, we do not need to manually go through issues, but users can automatically be notified that a change should be out now.
1 parent 1bd15f3 commit 3714d60

File tree

7 files changed

+331
-0
lines changed

7 files changed

+331
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: "Automation: Notify issues for release"
2+
on:
3+
release:
4+
types:
5+
- published
6+
workflow_dispatch:
7+
inputs:
8+
version:
9+
description: Which version to notify issues for
10+
required: false
11+
12+
# This workflow is triggered when a release is published
13+
jobs:
14+
release-comment-issues:
15+
runs-on: ubuntu-20.04
16+
name: 'Notify issues'
17+
steps:
18+
- name: Check out code
19+
uses: actions/checkout@v4
20+
21+
- name: Install dependencies
22+
run: yarn install --frozen-lockfile
23+
24+
- name: Get version
25+
id: get_version
26+
run: echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> $GITHUB_OUTPUT
27+
28+
- name: Comment on linked issues that are mentioned in release
29+
if: steps.get_version.outputs.version != ''
30+
uses: ./dev-packages/release-comment-issues-gh-action
31+
with:
32+
github_token: ${{ secrets.GITHUB_TOKEN }}
33+
version: ${{ steps.get_version.outputs.version }}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
extends: ['../../.eslintrc.js'],
3+
parserOptions: {
4+
sourceType: 'module',
5+
ecmaVersion: 'latest',
6+
},
7+
8+
overrides: [
9+
{
10+
files: ['*.mjs'],
11+
extends: ['@sentry-internal/sdk/src/base'],
12+
},
13+
],
14+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: 'release-comment-issues-gh-action'
2+
description: 'An internal Github Action to comment on related issues when a release is published.'
3+
inputs:
4+
github_token:
5+
required: true
6+
description: 'a github access token'
7+
version:
8+
required: true
9+
description: 'Which version was released'
10+
runs:
11+
using: 'node20'
12+
main: 'index.mjs'
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as core from '@actions/core';
2+
import { context, getOctokit } from '@actions/github';
3+
4+
const RELEASE_COMMENT_HEADING = '## A PR closing this issue has just been released 🚀';
5+
6+
async function run() {
7+
const { getInput } = core;
8+
9+
const githubToken = getInput('github_token');
10+
const version = getInput('version');
11+
12+
if (!githubToken || !version) {
13+
core.debug('Skipping because github_token or version are empty.');
14+
return;
15+
}
16+
17+
const { owner, repo } = context.repo;
18+
19+
const octokit = getOctokit(githubToken);
20+
21+
const release = await octokit.request('GET /repos/{owner}/{repo}/releases/tags/{tag}', {
22+
owner,
23+
repo,
24+
tag: version,
25+
headers: {
26+
'X-GitHub-Api-Version': '2022-11-28',
27+
},
28+
});
29+
30+
const prNumbers = extractPrsFromReleaseBody(release.data.body, { repo, owner });
31+
32+
if (!prNumbers.length) {
33+
core.debug('No PRs found in release body.');
34+
return;
35+
}
36+
37+
core.debug(`Found PRs in release body: ${prNumbers.join(', ')}`);
38+
39+
const linkedIssues = await Promise.all(
40+
prNumbers.map(prNumber => getLinkedIssuesForPr(octokit, { repo, owner, prNumber })),
41+
);
42+
43+
for (const pr of linkedIssues) {
44+
if (!pr.issues.length) {
45+
core.debug(`No linked issues found for PR #${pr.prNumber}`);
46+
continue;
47+
}
48+
49+
core.debug(`Linked issues for PR #${pr.prNumber}: ${pr.issues.map(issue => issue.number).join(',')}`);
50+
51+
for (const issue of pr.issues) {
52+
if (await hasExistingComment(octokit, { repo, owner, issueNumber: issue.number })) {
53+
core.debug(`Comment already exists for issue #${issue.number}`);
54+
continue;
55+
}
56+
57+
const body = `${RELEASE_COMMENT_HEADING}\n\nThis issue was referenced by PR #${pr.prNumber}, which was included in the [${version} release](https://github.com/${owner}/${repo}/releases/tag/${version}).`;
58+
59+
core.debug(`Creating comment for issue #${issue.number}`);
60+
61+
await octokit.rest.issues.createComment({
62+
repo,
63+
owner,
64+
issue_number: issue.number,
65+
body,
66+
});
67+
}
68+
}
69+
}
70+
71+
/**
72+
*
73+
* @param {string} body
74+
* @param {{ repo: string, owner: string}} options
75+
* @returns {number[]}
76+
*/
77+
function extractPrsFromReleaseBody(body, { repo, owner }) {
78+
const regex = new RegExp(`\\[#(\\d+)\\]\\(https:\\/\\/github\\.com\\/${owner}\\/${repo}\\/pull\\/(?:\\d+)\\)`, 'gm');
79+
const prNumbers = Array.from(new Set([...body.matchAll(regex)].map(match => parseInt(match[1]))));
80+
81+
return prNumbers.filter(number => !!number && !Number.isNaN(number));
82+
}
83+
84+
/**
85+
*
86+
* @param {ReturnType<import('@actions/github').getOctokit>} octokit
87+
* @param {{ repo: string, owner: string, prNumber: number}} options
88+
* @returns {Promise<{ prNumber: number, issues: {id: string, number: number}[] }>}
89+
*/
90+
async function getLinkedIssuesForPr(octokit, { repo, owner, prNumber }) {
91+
const res = await octokit.graphql(
92+
`
93+
query issuesForPr($owner: String!, $repo: String!, $prNumber: Int!) {
94+
repository(owner: $owner, name: $repo) {
95+
pullRequest(number: $prNumber) {
96+
id
97+
closingIssuesReferences (first: 50) {
98+
edges {
99+
node {
100+
id
101+
number
102+
}
103+
}
104+
}
105+
}
106+
}
107+
}`,
108+
{
109+
prNumber,
110+
owner,
111+
repo,
112+
},
113+
);
114+
115+
const issues = res.repository?.pullRequest?.closingIssuesReferences.edges.map(edge => edge.node);
116+
return {
117+
prNumber,
118+
issues,
119+
};
120+
}
121+
122+
/**
123+
*
124+
* @param {ReturnType<import('@actions/github').getOctokit>} octokit
125+
* @param {{ repo: string, owner: string, issueNumber: number}} options
126+
* @returns {Promise<boolean>}
127+
*/
128+
async function hasExistingComment(octokit, { repo, owner, issueNumber }) {
129+
const { data: commentList } = await octokit.rest.issues.listComments({
130+
repo,
131+
owner,
132+
issue_number: issueNumber,
133+
});
134+
135+
return commentList.some(comment => comment.body.startsWith(RELEASE_COMMENT_HEADING));
136+
}
137+
138+
run();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@sentry-internal/release-comment-issues-gh-action",
3+
"description": "An internal Github Action to comment on related issues when a release is published.",
4+
"version": "8.31.0",
5+
"license": "MIT",
6+
"engines": {
7+
"node": ">=18"
8+
},
9+
"private": true,
10+
"main": "index.mjs",
11+
"type": "module",
12+
"scripts": {
13+
"lint": "eslint . --format stylish",
14+
"fix": "eslint . --format stylish --fix"
15+
},
16+
"dependencies": {
17+
"@actions/core": "1.10.1",
18+
"@actions/github": "6.0.0"
19+
},
20+
"volta": {
21+
"extends": "../../package.json"
22+
}
23+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"dev-packages/size-limit-gh-action",
9494
"dev-packages/clear-cache-gh-action",
9595
"dev-packages/external-contributor-gh-action",
96+
"dev-packages/release-comment-issues-gh-action",
9697
"dev-packages/rollup-utils"
9798
],
9899
"devDependencies": {

0 commit comments

Comments
 (0)