xref: /aosp_15_r20/external/pigweed/docs/_static/js/changelog.js (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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