Skip to content

Commit e529f37

Browse files
committed
ci: add workflow to comment on issues resolved in the latest release
1 parent 64b96c1 commit e529f37

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: notify resolved issues
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: release tag to notify for (e.g. v2.0.0)
10+
required: true
11+
type: string
12+
dry-run:
13+
description: log the issues that would be commented on, without commenting
14+
default: true
15+
type: boolean
16+
notify-once:
17+
description: skip issues already notified for an earlier release (e.g. a backport)
18+
default: true
19+
type: boolean
20+
21+
permissions: {}
22+
23+
concurrency:
24+
group: notify-resolved-issues-${{ inputs.tag || github.event.release.tag_name }}
25+
26+
jobs:
27+
notify:
28+
if: github.repository_owner == 'nuxt' && !github.event.release.prerelease
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 15
31+
permissions:
32+
issues: write
33+
steps:
34+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35+
with:
36+
fetch-depth: 0
37+
persist-credentials: false
38+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
39+
with:
40+
node-version: 24.18.0
41+
42+
- run: node ./scripts/notify-resolved-issues.ts
43+
env:
44+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45+
TAG: ${{ inputs.tag || github.event.release.tag_name }}
46+
DRY_RUN: ${{ inputs.dry-run || false }}
47+
NOTIFY_ONCE: ${{ github.event_name != 'workflow_dispatch' || inputs.notify-once }}

scripts/notify-resolved-issues.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import process from 'node:process'
2+
import { execFileSync } from 'node:child_process'
3+
4+
const REPO = process.env.GITHUB_REPOSITORY || 'nuxt/image'
5+
const [OWNER, NAME] = REPO.split('/') as [string, string]
6+
const TOKEN = process.env.GITHUB_TOKEN
7+
const DRY_RUN = process.env.DRY_RUN === 'true'
8+
9+
const NOTIFY_ONCE = process.env.NOTIFY_ONCE !== 'false'
10+
const MARKER_PREFIX = '<!-- released-in:'
11+
12+
interface Issue {
13+
number: number
14+
state: string
15+
locked: boolean
16+
url: string
17+
repository: { nameWithOwner: string }
18+
prs: Set<number>
19+
}
20+
21+
function delay(ms: number) {
22+
return new Promise(resolve => setTimeout(resolve, ms))
23+
}
24+
25+
function git(...args: string[]) {
26+
return execFileSync('git', args, { encoding: 'utf-8' }).trim()
27+
}
28+
29+
async function api<T>(url: string, init?: RequestInit): Promise<T> {
30+
const res = await fetch(url, {
31+
...init,
32+
headers: {
33+
'authorization': `bearer ${TOKEN}`,
34+
'content-type': 'application/json',
35+
...init?.headers,
36+
},
37+
})
38+
const data = await res.json().catch(() => ({})) as T & { message?: string }
39+
if (!res.ok) {
40+
throw Object.assign(new Error(data.message || `${res.status} ${res.statusText}`), { status: res.status })
41+
}
42+
return data
43+
}
44+
45+
async function graphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
46+
const res = await api<{ data: T, errors?: { message: string }[] }>('https://api.github.com/graphql', {
47+
method: 'POST',
48+
body: JSON.stringify({ query, variables }),
49+
})
50+
if (res.errors?.length) {
51+
throw new Error(res.errors.map(e => e.message).join('\n'))
52+
}
53+
return res.data
54+
}
55+
56+
/**
57+
* The tag of the release we are notifying about, and the previous tag in its
58+
* own release line.
59+
*/
60+
function resolveTagRange() {
61+
const tag = process.env.TAG || process.env.GITHUB_REF_NAME
62+
const major = tag?.match(/^v(\d+)\./)?.[1]
63+
if (!tag || !major) {
64+
throw new Error(`Expected a release tag such as \`v2.0.0\`, received \`${tag}\`.`)
65+
}
66+
const previousTag = git('describe', '--tags', '--abbrev=0', '--match', `v${major}.*`, `refs/tags/${tag}^`)
67+
return { tag, previousTag }
68+
}
69+
70+
function getPullRequestNumbers(from: string, to: string) {
71+
const log = git('log', '--format=%s%n%b', `refs/tags/${from}..refs/tags/${to}`)
72+
const numbers = new Set<number>()
73+
for (const match of log.matchAll(/\(#(\d+)\)/g)) {
74+
numbers.add(Number(match[1]))
75+
}
76+
return [...numbers].sort((a, b) => a - b)
77+
}
78+
79+
async function getClosedIssues(prNumbers: number[]) {
80+
const issues = new Map<number, Issue>()
81+
82+
for (const prNumber of prNumbers) {
83+
const data = await graphql<{
84+
repository: {
85+
pullRequest: null | {
86+
merged: boolean
87+
closingIssuesReferences: { nodes: Omit<Issue, 'prs'>[] }
88+
}
89+
}
90+
}>(`
91+
query ($owner: String!, $name: String!, $number: Int!) {
92+
repository(owner: $owner, name: $name) {
93+
pullRequest(number: $number) {
94+
merged
95+
closingIssuesReferences(first: 50) {
96+
nodes { number state locked url repository { nameWithOwner } }
97+
}
98+
}
99+
}
100+
}
101+
`, { owner: OWNER, name: NAME, number: prNumber })
102+
103+
const pr = data.repository.pullRequest
104+
if (!pr?.merged) {
105+
continue
106+
}
107+
108+
for (const issue of pr.closingIssuesReferences.nodes) {
109+
// A PR can close issues in other repositories, which we have neither the
110+
// permission nor the intent to comment on.
111+
if (issue.repository.nameWithOwner !== REPO) {
112+
continue
113+
}
114+
115+
const existing = issues.get(issue.number)
116+
if (existing) {
117+
existing.prs.add(prNumber)
118+
continue
119+
}
120+
issues.set(issue.number, { ...issue, prs: new Set([prNumber]) })
121+
}
122+
}
123+
124+
return [...issues.values()]
125+
}
126+
127+
async function hasExistingComment(issueNumber: number, marker: string) {
128+
const comments = await api<{ body: string }[]>(`https://api.github.com/repos/${REPO}/issues/${issueNumber}/comments?per_page=100`)
129+
return comments.some(comment => comment.body?.includes(marker))
130+
}
131+
132+
function hasCommented(issueNumber: number, tag: string) {
133+
return hasExistingComment(issueNumber, NOTIFY_ONCE ? MARKER_PREFIX : `${MARKER_PREFIX}${tag} -->`)
134+
}
135+
136+
async function main() {
137+
if (!TOKEN) {
138+
throw new Error('`GITHUB_TOKEN` is required.')
139+
}
140+
141+
const { tag, previousTag } = resolveTagRange()
142+
console.info(`Comparing ${previousTag}...${tag}`)
143+
144+
const prNumbers = getPullRequestNumbers(previousTag, tag)
145+
console.info(`Found ${prNumbers.length} pull request(s) in the release.`)
146+
147+
const issues = await getClosedIssues(prNumbers)
148+
console.info(`Found ${issues.length} linked issue(s).`)
149+
150+
const marker = `${MARKER_PREFIX}${tag} -->`
151+
152+
for (const issue of issues) {
153+
const prs = [...issue.prs].map(n => `#${n}`).join(', ')
154+
const body = [
155+
marker,
156+
`This issue was resolved by ${prs}, which was released in [${tag}](https://github.com/${REPO}/releases/tag/${tag}).`,
157+
'',
158+
'If you are still seeing this problem after upgrading, please let us know (ideally with an updated reproduction) and we will take another look.',
159+
].join('\n')
160+
161+
if (issue.locked) {
162+
console.log(`Skipping #${issue.number} - issue is locked.`)
163+
continue
164+
}
165+
166+
if (DRY_RUN) {
167+
console.log(`[dry run] would comment on #${issue.number} (resolved by ${prs})`)
168+
continue
169+
}
170+
171+
if (await hasCommented(issue.number, tag)) {
172+
console.log(`Skipping #${issue.number} - already notified.`)
173+
continue
174+
}
175+
176+
try {
177+
await comment(issue.number, body)
178+
console.log(`Commented on #${issue.number} (resolved by ${prs}).`)
179+
}
180+
catch (error) {
181+
// One inaccessible issue (locked while we ran, transferred, deleted) should
182+
// not stop the rest of the release from being notified.
183+
console.warn(`Could not comment on #${issue.number}:`, (error as Error).message)
184+
}
185+
186+
// GitHub asks for at least a second between write requests to avoid
187+
// tripping the secondary rate limit.
188+
await delay(1000)
189+
}
190+
}
191+
192+
async function comment(issueNumber: number, body: string, attempt = 1): Promise<void> {
193+
try {
194+
await api(`https://api.github.com/repos/${REPO}/issues/${issueNumber}/comments`, {
195+
method: 'POST',
196+
body: JSON.stringify({ body }),
197+
})
198+
}
199+
catch (error) {
200+
const { status, message } = error as { status?: number, message?: string }
201+
const isRateLimited = status === 403 && /rate limit|abuse/i.test(message || '')
202+
if (isRateLimited && attempt <= 5) {
203+
const backoff = 30_000 * attempt
204+
console.warn(`Rate limited, retrying #${issueNumber} in ${backoff / 1000}s.`)
205+
await delay(backoff)
206+
return comment(issueNumber, body, attempt + 1)
207+
}
208+
throw error
209+
}
210+
}
211+
212+
main().catch((error) => {
213+
console.error(error)
214+
process.exit(1)
215+
})

0 commit comments

Comments
 (0)