1// Copyright 2023 The Pigweed Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); you may not 4// use this file except in compliance with the License. You may obtain a copy of 5// the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12// License for the specific language governing permissions and limitations under 13// the License. 14 15// This file powers the changelog tool in //docs/contributing/changelog.rst. 16// We use this tool to speed up the generation of bi-weekly changelog 17// updates. It fetches the commits over a user-specified timeframe, derives 18// a little metadata about each commit, organizes the commits, and renders 19// the data as reStructuredText. It doesn't completely automate the changelog 20// update process (a contributor still needs to manually write the summaries) 21// but it does reduce a lot of the toil. 22 23// Get the commits from the user-specified timeframe. 24async function get() { 25 const start = `${document.querySelector('#start').value}T00:00:00Z`; 26 const end = `${document.querySelector('#end').value}T23:59:59Z`; 27 document.querySelector('#status').textContent = `Getting commit data...`; 28 let page = 1; 29 let done = false; 30 let commits = []; 31 while (!done) { 32 // The commits are pulled from the Pigweed mirror on GitHub because 33 // GitHub provides a better API than Gerrit for this task. 34 let url = new URL(`https://api.github.com/repos/google/pigweed/commits`); 35 const params = { since: start, until: end, per_page: 100, page }; 36 Object.keys(params).forEach((key) => 37 url.searchParams.append(key, params[key]), 38 ); 39 const headers = { 40 Accept: 'application/vnd.github+json', 41 'X-GitHub-Api-Version': '2022-11-28', 42 }; 43 const response = await fetch(url.href, { method: 'GET', headers }); 44 if (!response.ok) { 45 document.querySelector('#status').textContent = 46 'An error occurred while fetching the commit data.'; 47 console.error(response); 48 return; 49 } 50 const data = await response.json(); 51 if (data.length === 0) { 52 done = true; 53 continue; 54 } 55 commits = commits.concat(data); 56 page += 1; 57 } 58 return commits; 59} 60 61// Weed out all the data that GitHub provides that we don't need. 62// Also, parse the "subject" of the commit, which is the first line 63// of the commit message. 64async function normalize(commits) { 65 function parseSubject(message) { 66 const end = message.indexOf('\n\n'); 67 return message.substring(0, end); 68 } 69 70 document.querySelector('#status').textContent = 'Normalizing data...'; 71 let normalizedCommits = []; 72 commits.forEach((commit) => { 73 normalizedCommits.push({ 74 sha: commit.sha, 75 message: commit.commit.message, 76 date: commit.commit.committer.date, 77 subject: parseSubject(commit.commit.message), 78 }); 79 }); 80 return normalizedCommits; 81} 82 83// Derive Pigweed-specific metadata from each commit. 84async function annotate(commits) { 85 function categorize(preamble) { 86 if (preamble.startsWith('third_party')) { 87 return 'Third party'; 88 } else if (preamble.startsWith('pw_')) { 89 return 'Modules'; 90 } else if (preamble.startsWith('targets')) { 91 return 'Targets'; 92 } else if (['build', 'bazel', 'cmake'].includes(preamble)) { 93 return 'Build'; 94 } else if (['rust', 'python'].includes(preamble)) { 95 return 'Language support'; 96 } else if (['zephyr', 'freertos'].includes(preamble)) { 97 return 'OS support'; 98 } else if (preamble.startsWith('SEED')) { 99 return 'SEEDs'; 100 } else if (preamble === 'docs') { 101 return 'Docs'; 102 } else { 103 return 'Miscellaneous'; 104 } 105 } 106 107 function parseTitle(message) { 108 const start = message.indexOf(':') + 1; 109 const tmp = message.substring(start); 110 const end = tmp.indexOf('\n'); 111 return tmp.substring(0, end).trim(); 112 } 113 114 function parseBugUrl(message, bugLabel) { 115 const start = message.indexOf(bugLabel); 116 const tmp = message.substring(start); 117 const end = tmp.indexOf('\n'); 118 let bug = tmp.substring(bugLabel.length, end).trim(); 119 if (bug.startsWith('b/')) bug = bug.replace('b/', ''); 120 return `https://issues.pigweed.dev/issues/${bug}`; 121 } 122 123 function parseChangeUrl(message) { 124 const label = 'Reviewed-on:'; 125 const start = message.indexOf(label); 126 const tmp = message.substring(start); 127 const end = tmp.indexOf('\n'); 128 const change = tmp.substring(label.length, end).trim(); 129 return change; 130 } 131 132 for (let i = 0; i < commits.length; i++) { 133 let commit = commits[i]; 134 const { message, sha } = commit; 135 commit.url = `https://cs.opensource.google/pigweed/pigweed/+/${sha}`; 136 commit.change = parseChangeUrl(message); 137 commit.summary = message.substring(0, message.indexOf('\n')); 138 commit.preamble = message.substring(0, message.indexOf(':')); 139 commit.category = categorize(commit.preamble); 140 commit.title = parseTitle(message); 141 // We use syntax like "pw_{tokenizer,string}" to indicate that a commit 142 // affects both pw_tokenizer and pw_string. The next logic detects this 143 // situation. The same commit gets duplicated to each module's section. 144 // The rationale for the duplication is that someone might only care about 145 // pw_tokenizer and they should be able to see all commits that affected 146 // in a single place. 147 if (commit.preamble.indexOf('{') > -1) { 148 commit.topics = []; 149 const topics = commit.preamble 150 .substring( 151 commit.preamble.indexOf('{') + 1, 152 commit.preamble.indexOf('}'), 153 ) 154 .split(','); 155 topics.forEach((topic) => commit.topics.push(`pw_${topic}`)); 156 } else { 157 commit.topics = [commit.preamble]; 158 } 159 const bugLabels = ['Bug:', 'Fixes:', 'Fixed:']; 160 for (let i = 0; i < bugLabels.length; i++) { 161 const bugLabel = bugLabels[i]; 162 if (message.indexOf(bugLabel) > -1) { 163 const bugUrl = parseBugUrl(message, bugLabel); 164 const bugId = bugUrl.substring(bugUrl.lastIndexOf('/') + 1); 165 commit.issue = { id: bugId, url: bugUrl }; 166 break; 167 } 168 } 169 } 170 return commits; 171} 172 173// If there are any categories of commits that we don't want to surface 174// in the changelog, this function is where we drop them. 175async function filter(commits) { 176 const filteredCommits = commits.filter((commit) => { 177 if (commit.preamble === 'roll') return false; 178 return true; 179 }); 180 return filteredCommits; 181} 182 183// Render the commit data as reStructuredText. 184async function render(commits) { 185 function organizeByCategoryAndTopic(commits) { 186 let categories = {}; 187 commits.forEach((commit) => { 188 const { category } = commit; 189 if (!(category in categories)) categories[category] = {}; 190 commit.topics.forEach((topic) => { 191 topic in categories[category] 192 ? categories[category][topic].push(commit) 193 : (categories[category][topic] = [commit]); 194 }); 195 }); 196 return categories; 197 } 198 199 async function createRestSection(commits) { 200 const locale = 'en-US'; 201 const format = { day: '2-digit', month: 'short', year: 'numeric' }; 202 const start = new Date( 203 document.querySelector('#start').value, 204 ).toLocaleDateString(locale, format); 205 const end = new Date( 206 document.querySelector('#end').value, 207 ).toLocaleDateString(locale, format); 208 let rest = ''; 209 rest += '.. _docs-changelog-latest:\n\n'; 210 const title = `${end}`; 211 rest += `${'-'.repeat(title.length)}\n`; 212 rest += `${title}\n`; 213 rest += `${'-'.repeat(title.length)}\n\n`; 214 rest += '.. changelog_highlights_start\n\n'; 215 rest += `Highlights (${start} to ${end}):\n\n`; 216 rest += '* **<Highlight 1>**: Description\n'; 217 rest += '* **<Highlight 2>**: Description\n'; 218 rest += '* **<Highlight 3>**: Description\n\n'; 219 rest += '.. changelog_highlights_end\n\n'; 220 rest += 'Active SEEDs\n'; 221 rest += '============\n'; 222 rest += 'Help shape the future of Pigweed! Please visit :ref:`seed-0000`\n'; 223 rest += 'and leave feedback on the RFCs (i.e. SEEDs) marked\n'; 224 rest += '``Open for Comments``.\n\n'; 225 rest += '.. Note: There is space between the following section headings\n'; 226 rest += '.. and commit lists to remind you to write a summary for each\n'; 227 rest += '.. section. If a summary is not needed, delete the extra\n'; 228 rest += '.. space.\n\n'; 229 const categories = [ 230 'Modules', 231 'Build systems', 232 'Hardware targets', 233 'Language support', 234 'OS support', 235 'Docs', 236 'SEEDs', 237 'Third-party software', 238 'Miscellaneous', 239 ]; 240 for (let i = 0; i < categories.length; i++) { 241 const category = categories[i]; 242 if (!(category in commits)) continue; 243 rest += `${category}\n`; 244 rest += `${'='.repeat(category.length)}\n\n`; 245 let topics = Object.keys(commits[category]); 246 topics.sort(); 247 topics.forEach((topic) => { 248 // Some topics should not be rendered because they're redundant. 249 // E.g. we already have a "Docs" H3 heading so we don't need another 250 // "docs" H4 heading right after it. 251 const topicsToSkip = ['docs']; 252 if (!topicsToSkip.includes(topic)) { 253 rest += `${topic}\n`; 254 rest += `${'-'.repeat(topic.length)}\n\n\n`; 255 } 256 commits[category][topic].forEach((commit) => { 257 // Escape any backticks that are used in the commit message. 258 const change = commit.change.replaceAll('`', '\\`'); 259 // Use double underscores to make the links anonymous so that Sphinx 260 // doesn't error when the same link is used multiple times. 261 // https://github.com/sphinx-doc/sphinx/issues/3921 262 rest += `* \`${commit.title}\n <${change}>\`__\n`; 263 if (commit.issue) 264 rest += ` (issue \`#${commit.issue.id} <${commit.issue.url}>\`__)\n`; 265 }); 266 rest += '\n'; 267 }); 268 } 269 const section = document.createElement('section'); 270 const heading = document.createElement('h2'); 271 section.appendChild(heading); 272 const pre = document.createElement('pre'); 273 section.appendChild(pre); 274 const code = document.createElement('code'); 275 pre.appendChild(code); 276 code.textContent = rest; 277 try { 278 await navigator.clipboard.writeText(rest); 279 document.querySelector('#status').textContent = 280 'Done! The output was copied to your clipboard.'; 281 } catch (error) { 282 document.querySelector('#status').textContent = 'Done!'; 283 } 284 return section; 285 } 286 287 const organizedCommits = organizeByCategoryAndTopic(commits); 288 document.querySelector('#status').textContent = 'Rendering data...'; 289 const container = document.createElement('div'); 290 const restSection = await createRestSection(organizedCommits); 291 container.appendChild(restSection); 292 return container; 293} 294 295// Use the placeholder in the start and end date text inputs to guide users 296// towards the correct date format. 297function populateDates() { 298 // Suggest the start date. 299 let twoWeeksAgo = new Date(); 300 twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); 301 const twoWeeksAgoFormatted = twoWeeksAgo.toISOString().slice(0, 10); 302 document.querySelector('#start').placeholder = twoWeeksAgoFormatted; 303 // Suggest the end date. 304 const today = new Date(); 305 const todayFormatted = today.toISOString().slice(0, 10); 306 document.querySelector('#end').placeholder = todayFormatted; 307} 308 309// Enable the "generate" button only when the start and end dates are valid. 310function validateDates() { 311 const dateFormat = /^\d{4}-\d{2}-\d{2}$/; 312 const start = document.querySelector('#start').value; 313 const end = document.querySelector('#end').value; 314 const status = document.querySelector('#status'); 315 let generate = document.querySelector('#generate'); 316 if (!start.match(dateFormat) || !end.match(dateFormat)) { 317 generate.disabled = true; 318 status.textContent = 'Invalid start or end date (should be YYYY-MM-DD)'; 319 } else { 320 generate.disabled = false; 321 status.textContent = 'Ready to generate!'; 322 } 323} 324 325// Set up the date placeholder and validation stuff when the page loads. 326window.addEventListener('load', () => { 327 populateDates(); 328 document.querySelector('#start').addEventListener('keyup', validateDates); 329 document.querySelector('#end').addEventListener('keyup', validateDates); 330}); 331 332// Run through the whole get/normalize/annotate/filter/render pipeline when 333// the user clicks the "generate" button. 334document.querySelector('#generate').addEventListener('click', async (e) => { 335 e.target.disabled = true; 336 const rawCommits = await get(); 337 const normalizedCommits = await normalize(rawCommits); 338 const annotatedCommits = await annotate(normalizedCommits); 339 const filteredCommits = await filter(annotatedCommits); 340 const output = await render(filteredCommits); 341 document.querySelector('#output').innerHTML = ''; 342 document.querySelector('#output').appendChild(output); 343 e.target.disabled = false; 344}); 345