|
| 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