• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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\n';
217    rest += '* Highlight #2\n';
218    rest += '* Highlight #3\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',
232      'Targets',
233      'Language support',
234      'OS support',
235      'Docs',
236      'SEEDs',
237      'Third party',
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        rest += `${topic}\n`;
249        rest += `${'-'.repeat(topic.length)}\n\n\n`;
250        commits[category][topic].forEach((commit) => {
251          const change = commit.change.replaceAll('`', '`');
252          // The double underscores are signficant:
253          // https://github.com/sphinx-doc/sphinx/issues/3921
254          rest += `* \`${commit.title}\n  <${change}>\`__\n`;
255          if (commit.issue)
256            rest += `  (issue \`#${commit.issue.id} <${commit.issue.url}>\`__)\n`;
257        });
258        rest += '\n';
259      });
260    }
261    const section = document.createElement('section');
262    const heading = document.createElement('h2');
263    section.appendChild(heading);
264    const pre = document.createElement('pre');
265    section.appendChild(pre);
266    const code = document.createElement('code');
267    pre.appendChild(code);
268    code.textContent = rest;
269    try {
270      await navigator.clipboard.writeText(rest);
271      document.querySelector('#status').textContent =
272        'Done! The output was copied to your clipboard.';
273    } catch (error) {
274      document.querySelector('#status').textContent = 'Done!';
275    }
276    return section;
277  }
278
279  const organizedCommits = organizeByCategoryAndTopic(commits);
280  document.querySelector('#status').textContent = 'Rendering data...';
281  const container = document.createElement('div');
282  const restSection = await createRestSection(organizedCommits);
283  container.appendChild(restSection);
284  return container;
285}
286
287// Use the placeholder in the start and end date text inputs to guide users
288// towards the correct date format.
289function populateDates() {
290  // Suggest the start date.
291  let twoWeeksAgo = new Date();
292  twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
293  const twoWeeksAgoFormatted = twoWeeksAgo.toISOString().slice(0, 10);
294  document.querySelector('#start').placeholder = twoWeeksAgoFormatted;
295  // Suggest the end date.
296  const today = new Date();
297  const todayFormatted = today.toISOString().slice(0, 10);
298  document.querySelector('#end').placeholder = todayFormatted;
299}
300
301// Enable the "generate" button only when the start and end dates are valid.
302function validateDates() {
303  const dateFormat = /^\d{4}-\d{2}-\d{2}$/;
304  const start = document.querySelector('#start').value;
305  const end = document.querySelector('#end').value;
306  const status = document.querySelector('#status');
307  let generate = document.querySelector('#generate');
308  if (!start.match(dateFormat) || !end.match(dateFormat)) {
309    generate.disabled = true;
310    status.textContent = 'Invalid start or end date (should be YYYY-MM-DD)';
311  } else {
312    generate.disabled = false;
313    status.textContent = 'Ready to generate!';
314  }
315}
316
317// Set up the date placeholder and validation stuff when the page loads.
318window.addEventListener('load', () => {
319  populateDates();
320  document.querySelector('#start').addEventListener('keyup', validateDates);
321  document.querySelector('#end').addEventListener('keyup', validateDates);
322});
323
324// Run through the whole get/normalize/annotate/filter/render pipeline when
325// the user clicks the "generate" button.
326document.querySelector('#generate').addEventListener('click', async (e) => {
327  e.target.disabled = true;
328  const rawCommits = await get();
329  const normalizedCommits = await normalize(rawCommits);
330  const annotatedCommits = await annotate(normalizedCommits);
331  const filteredCommits = await filter(annotatedCommits);
332  const output = await render(filteredCommits);
333  document.querySelector('#output').innerHTML = '';
334  document.querySelector('#output').appendChild(output);
335  e.target.disabled = false;
336});
337