listReleasedIssues.js
1 #!/usr/bin/env node 2 3 const owner = "Budibase" 4 const repo = "budibase" 5 const releasesUrl = `https://github.com/${owner}/${repo}/releases` 6 const issueUrl = `https://github.com/${owner}/${repo}/issues` 7 const repoPath = `${owner}/${repo}` 8 const escapedRepoPath = repoPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 9 const hrefPattern = new RegExp( 10 `href="(?:https://github\\.com/)?${escapedRepoPath}/(?:issues|pull)/(\\d+)"`, 11 "gi" 12 ) 13 const dataUrlPattern = new RegExp( 14 `data-url="https://github\\.com/${escapedRepoPath}/issues/(\\d+)"`, 15 "gi" 16 ) 17 const mentionPattern = 18 /<a class="user-mention[^>]*href="https:\/\/github\.com\/([^"/]+)"/i 19 const userAgent = "budibase-scripts/listReleasedIssues" 20 21 const ownerMap = new Map([ 22 ["Dakuan", "Dom"], 23 ["andz-bb", "Andy"], 24 ["PClmnt", "Peter"], 25 ["deanhannigan", "Dean"], 26 ["adrinr", "Adria"], 27 ["melohagan", "Mel"], 28 ["calexiou", "Christos"], 29 ]) 30 31 if (typeof fetch !== "function") { 32 console.error( 33 "This script requires Node.js v18 or newer with global fetch support" 34 ) 35 process.exit(1) 36 } 37 38 const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) 39 40 const fetchText = async url => { 41 const response = await fetch(url, { 42 headers: { 43 "User-Agent": userAgent, 44 Accept: "text/html", 45 }, 46 }) 47 48 if (response.status === 429) { 49 const retryAfter = 50 Number.parseInt(response.headers.get("retry-after") ?? "0", 10) || 5 51 await sleep(retryAfter * 1000) 52 return fetchText(url) 53 } 54 55 if (!response.ok) { 56 const body = await response.text().catch(() => "<unable to read body>") 57 throw new Error( 58 `Request failed with ${response.status} ${response.statusText}\nURL: ${url}\nBody: ${body}` 59 ) 60 } 61 62 return response.text() 63 } 64 65 const decodeHtml = html => 66 html 67 .replace(/'/g, "'") 68 .replace(/"/g, '"') 69 .replace(/&/g, "&") 70 .replace(/</g, "<") 71 .replace(/>/g, ">") 72 .replace(/ /g, " ") 73 74 const stripHtml = html => 75 decodeHtml(html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ")).trim() 76 77 const getWeekBounds = now => { 78 const current = now ?? new Date() 79 const todayUtc = new Date( 80 Date.UTC( 81 current.getUTCFullYear(), 82 current.getUTCMonth(), 83 current.getUTCDate() 84 ) 85 ) 86 const day = todayUtc.getUTCDay() 87 const daysSinceMonday = (day + 6) % 7 88 89 todayUtc.setUTCDate(todayUtc.getUTCDate() - daysSinceMonday) 90 91 const startOfThisWeek = todayUtc 92 const startOfLastWeek = new Date(startOfThisWeek) 93 startOfLastWeek.setUTCDate(startOfLastWeek.getUTCDate() - 7) 94 95 return { startOfLastWeek, startOfThisWeek } 96 } 97 98 const extractNumbers = html => { 99 const numbers = new Set() 100 101 for (const match of html.matchAll(hrefPattern)) { 102 const number = Number.parseInt(match[1], 10) 103 if (!Number.isNaN(number)) { 104 numbers.add(number) 105 } 106 } 107 108 for (const match of html.matchAll(dataUrlPattern)) { 109 const number = Number.parseInt(match[1], 10) 110 if (!Number.isNaN(number)) { 111 numbers.add(number) 112 } 113 } 114 115 return Array.from(numbers) 116 } 117 118 const extractOwner = (html, fallbackText) => { 119 const mentionMatch = html.match(mentionPattern) 120 if (mentionMatch) { 121 return mentionMatch[1] 122 } 123 124 const textMatch = fallbackText.match(/@([A-Za-z0-9-]+)/) 125 if (textMatch) { 126 return textMatch[1] 127 } 128 129 return "Unknown" 130 } 131 132 const parseReleaseSections = html => { 133 const sections = 134 html.match(/<section aria-labelledby="hd-[^"]+">[\s\S]*?<\/section>/g) ?? [] 135 return sections.map(section => { 136 const nameMatch = section.match( 137 /<h2 class="sr-only" id="hd-[^"]+">([\s\S]*?)<\/h2>/ 138 ) 139 const name = nameMatch ? stripHtml(nameMatch[1]) : "Unknown release" 140 141 const publishedMatch = section.match( 142 /<relative-time[^>]*datetime="([^"]+)"/ 143 ) 144 const publishedAt = publishedMatch ? new Date(publishedMatch[1]) : null 145 146 const bodyMatch = section.match( 147 /<div[^>]*class="markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/ 148 ) 149 const bodyHtml = bodyMatch ? bodyMatch[1] : "" 150 151 const listItems = [...bodyHtml.matchAll(/<li>([\s\S]*?)<\/li>/g)].map( 152 match => match[1] 153 ) 154 155 const entries = listItems.map(itemHtml => { 156 const text = stripHtml(itemHtml) 157 const numbers = extractNumbers(itemHtml) 158 const firstLinkMatch = itemHtml.match(hrefPattern) 159 const primaryUrl = firstLinkMatch 160 ? (firstLinkMatch[0].match(/href="([^"]+)"/)?.[1] ?? null) 161 : null 162 const owner = extractOwner(itemHtml, text) 163 164 let title = text 165 const byIndex = title.toLowerCase().indexOf(" by ") 166 if (byIndex > 0) { 167 title = title.slice(0, byIndex).trim() 168 } 169 170 return { 171 title: title || text, 172 text, 173 numbers, 174 primaryUrl, 175 owner, 176 publishedAt, 177 } 178 }) 179 180 return { 181 publishedAt, 182 entries, 183 } 184 }) 185 } 186 187 const collectReleases = async ({ since, until }) => { 188 const releases = [] 189 let page = 1 190 let reachedOlderReleases = false 191 192 while (!reachedOlderReleases) { 193 const url = page === 1 ? releasesUrl : `${releasesUrl}?page=${page}` 194 const html = await fetchText(url) 195 const sections = parseReleaseSections(html) 196 197 if (sections.length === 0) { 198 break 199 } 200 201 for (const section of sections) { 202 if (!section.publishedAt) { 203 continue 204 } 205 206 if (section.publishedAt < since) { 207 reachedOlderReleases = true 208 } 209 210 if (section.publishedAt >= since && section.publishedAt < until) { 211 releases.push(section) 212 } 213 } 214 215 if (reachedOlderReleases) { 216 break 217 } 218 219 const hasNextPage = /rel="next"/.test(html) 220 if (!hasNextPage) { 221 break 222 } 223 224 page += 1 225 } 226 227 return releases 228 } 229 230 const escapeCell = value => String(value ?? "").replace(/\|/g, "\\|") 231 232 const formatLinkLabel = (title, number, url) => { 233 const type = url.includes("/pull/") ? "Pull Request" : "Issue" 234 const display = `${title} · ${type} #${number} · ${repo}` 235 return `[${display}](${url})` 236 } 237 238 const mapOwner = ownerLogin => ownerMap.get(ownerLogin) ?? ownerLogin 239 240 const main = async () => { 241 const { startOfLastWeek, startOfThisWeek } = getWeekBounds() 242 243 console.log( 244 `Gathering releases published between ${startOfLastWeek.toISOString()} and ${startOfThisWeek.toISOString()}` 245 ) 246 247 const releases = await collectReleases({ 248 since: startOfLastWeek, 249 until: startOfThisWeek, 250 }) 251 252 if (releases.length === 0) { 253 console.log("No releases were published last week.") 254 return 255 } 256 257 const rows = [] 258 259 releases.forEach(release => { 260 release.entries.forEach(entry => { 261 entry.numbers.forEach(number => { 262 rows.push({ 263 number, 264 title: entry.title, 265 url: entry.primaryUrl ?? `${issueUrl}/${number}`, 266 owner: entry.owner, 267 publishedAt: entry.publishedAt, 268 }) 269 }) 270 }) 271 }) 272 273 const uniqueRows = new Map() 274 rows.forEach(row => { 275 if (!uniqueRows.has(row.number)) { 276 uniqueRows.set(row.number, row) 277 } 278 }) 279 280 const sortedRows = Array.from(uniqueRows.values()).sort( 281 (a, b) => a.publishedAt - b.publishedAt || a.number - b.number 282 ) 283 284 if (sortedRows.length === 0) { 285 console.log( 286 "No issue or pull request references were found in last week's release notes." 287 ) 288 return 289 } 290 291 console.log("") 292 console.log("| Github Link | Owner |") 293 console.log("| --- | --- |") 294 295 sortedRows.forEach(row => { 296 const link = formatLinkLabel(row.title, row.number, row.url) 297 const ownerCell = escapeCell(mapOwner(row.owner)) 298 console.log(`| ${link} | ${ownerCell} |`) 299 }) 300 } 301 302 main().catch(error => { 303 console.error(error.message) 304 process.exit(1) 305 })