/ scripts / listReleasedIssues.js
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(/&#39;/g, "'")
 68      .replace(/&quot;/g, '"')
 69      .replace(/&amp;/g, "&")
 70      .replace(/&lt;/g, "<")
 71      .replace(/&gt;/g, ">")
 72      .replace(/&nbsp;/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  })