Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 1
#!/usr/bin/env node
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
import chalk from 'chalk';
18
import { getAllComponents } from './components.mjs';
19
import { getCombinedNotesByComponent, deleteAllNotes } from './note.mjs';
20
import { getNoteName } from './noteTypes.mjs';
21
import { writeFile, readFile, unlink } from 'fs/promises';
22
import { join as joinPath } from 'path';
23
import logger from './logger.mjs';
24
import { getCurrentVersion } from './helpers.mjs';
25
 
26
/**
27
 * Helper to fetch the current notes from a file.
28
 *
29
 * @param {string} file
30
 * @returns {Promise<string>}
31
 */
32
const getCurrentNotes = async (file) => {
33
    try {
34
        return await readFile(file, 'utf8');
35
    } catch (error) {
36
        return null;
37
    }
38
}
39
 
40
/**
41
 * Update the UPGRADING.md file.
42
 *
43
 * @param {string} upgradeNotes
44
 * @param {Object} options
45
 * @param {boolean} options.deleteNotes
46
 * @returns {Promise<void>}
47
 */
48
const updateUpgradeNotes = async (upgradeNotes, options) => {
49
    const fileName = 'UPGRADING.md';
50
    // Write the notes to a file.
51
    logger.info(`Writing notes to ${chalk.underline(chalk.bold(fileName))}`);
52
    // Prepend to the existing file.
53
    const existingContent = await getCurrentNotes(fileName);
54
    if (existingContent) {
55
        await writeFile(fileName, getUpdatedNotes(existingContent, upgradeNotes));
56
    } else {
57
        // This should not normally happen.
58
        await writeFile(fileName, upgradeNotes);
59
    }
60
 
61
    if (options.deleteNotes) {
62
        logger.warn(`>>> Deleting all notes <<<`)
63
        // Delete the notes.
64
        deleteAllNotes();
65
    }
66
};
67
 
68
/**
69
 * Create the current summary notes.
70
 *
71
 * @param {string} upgradeNotes
72
 * @returns {Promise<void>}
73
 */
74
const createCurrentSummary = async (upgradeNotes) => {
75
    const fileName = 'UPGRADING-CURRENT.md';
76
    const notes = `# Moodle upgrade notes\n\n${upgradeNotes}`;
77
    await writeFile(fileName, notes);
78
 
79
    logger.info(`Running upgrade notes written to ${chalk.underline(chalk.bold(fileName))}`);
80
};
81
 
82
/**
83
 * Get the indexes of the lines that contain the version headings.
84
 *
85
 * @param {array<string>} lines
86
 * @returns {array<object>}
87
 */
88
const getVersionLineIndexes = (lines) => {
89
    const h2Indexes = [];
90
    lines.forEach((line, index) => {
91
        const matches = line.match(/^##\s(?<version>.*)$/);
92
        if (matches) {
93
            h2Indexes.push({
94
                index,
95
                line,
96
                version: matches.groups.version,
97
            });
98
        }
99
    });
100
 
101
    return h2Indexes;
102
};
103
 
104
/**
105
 * Find the index of the Unreleased heading.
106
 *
107
 * @param {array<object>} versionHeadings
108
 * @returns {number}
109
 */
110
const findUnreleasedHeadingIndex = (versionHeadings) => versionHeadings.findIndex((heading) => {
111
    if (heading.version === 'Unreleased') {
112
        // Used if version cannot be guessed.
113
        return true;
114
    }
115
 
116
    if (heading.version.endsWith('+')) {
117
        // Weekly release for a stable branch.
118
        return true;
119
    }
120
 
121
    if (heading.version.match(/beta|rc\d/)) {
122
        // Beta and RC rolls are treated as weeklies.
123
        return true;
124
    }
125
 
126
    if (heading.version.endsWith('dev')) {
127
        // Development version.
128
        return true;
129
    }
130
 
131
    return false;
132
});
133
 
134
/**
135
 * Get the before and after content, to facilitate replacing any existing Unreleased notes.
136
 *
137
 * @param {array<string>} lines
138
 * @returns {Object} {beforeContent: string, afterContent: string}
139
 */
140
const getBeforeAndAfterContent = (lines) => {
141
    const existingLines = lines.split('\n');
142
    const versionHeadings = getVersionLineIndexes(existingLines);
143
 
144
    if (versionHeadings.length > 0) {
145
        const unreleasedHeadingIndex = findUnreleasedHeadingIndex(versionHeadings);
146
        if (unreleasedHeadingIndex !== -1) {
147
            const beforeContent = existingLines.slice(0, versionHeadings[unreleasedHeadingIndex].index).join('\n');
148
            if (versionHeadings.length > unreleasedHeadingIndex + 1) {
149
                const afterContent = existingLines.slice(versionHeadings[unreleasedHeadingIndex + 1].index).join('\n');
150
                return {
151
                    beforeContent,
152
                    afterContent,
153
                };
154
            }
155
            return {
156
                beforeContent,
157
                afterContent: '',
158
            };
159
        }
160
 
161
        return {
162
            beforeContent: existingLines.slice(0, versionHeadings[0].index).join('\n'),
163
            afterContent: existingLines.slice(versionHeadings[0].index).join('\n'),
164
        };
165
    }
166
 
167
    return {
168
        beforeContent: existingLines.join('\n'),
169
        afterContent: '',
170
    }
171
};
172
 
173
/**
174
 * Get the notes for the component.
175
 *
176
 * @param {string} types
177
 * @param {Number} headingLevel
178
 * @returns {string}
179
 */
180
const getNotesForComponent = (types, headingLevel) => {
181
    let upgradeNotes = '';
182
    Object.entries(types).forEach(([type, notes]) => {
183
        upgradeNotes += '#'.repeat(headingLevel);
184
        upgradeNotes += ` ${getNoteName(type)}\n\n`;
185
        notes.forEach(({ message, issueNumber }) => {
186
            // Split the message into lines, removing empty lines.
187
            const messageLines = message
188
                .split('\n')
189
                // Remove empty lines between tables, and list entries, but not after lists.
190
                .filter((line, index, lines) => {
191
                    if (line.trim().length === 0) {
192
                        // This line is empty.
193
 
194
                        // If it's the first line in the file, remove it.
195
                        if (index === 0) {
196
                            return false;
197
                        }
198
 
199
                        // This is the last line in the file, remove it.
200
                        if (index === lines.length - 1) {
201
                            return false;
202
                        }
203
 
204
                        // If the previous line relates to a table, remove this line.
205
                        if (lines[index - 1].match(/^\s*\|/)) {
206
                            return false;
207
                        }
208
 
209
                        // If the next line is also empty, do not remove this line.
210
                        if (lines[index + 1].trim().length === 0) {
211
                            return true;
212
                        }
213
 
214
                        // Do not remove the line if the previous line was a list item.
215
                        if (lines[index - 1].match(/^\s*[-*]\s/)) {
216
                            return true;
217
                        }
218
 
219
                        if (lines[index - 1].match(/^\s*\d+\.\s/)) {
220
                            return true;
221
                        }
222
 
223
                        // Preserve all other empty lines by default.
224
                        return true;
225
                    }
226
 
227
                    // Keep any line which has content.
228
                    return true;
229
                });
230
 
231
 
232
            const firstLine = messageLines.shift().trim();
233
            upgradeNotes += `- ${firstLine}\n`;
234
 
235
            messageLines
236
                .forEach((line) => {
237
                    upgradeNotes += `  ${line}`.trimEnd() + `\n`;
238
                });
239
            upgradeNotes += `\n  For more information see [${issueNumber}](https://tracker.moodle.org/browse/${issueNumber})\n`;
240
        });
241
        upgradeNotes += '\n';
242
    });
243
 
244
    return upgradeNotes;
245
};
246
 
247
/**
248
 * Get the updated notes mixed with existing content.
249
 *
250
 * @param {string} existingContent
251
 * @param {string} upgradeNotes
252
 */
253
const getUpdatedNotes = (existingContent, upgradeNotes) => {
254
    const { beforeContent, afterContent } = getBeforeAndAfterContent(existingContent);
255
    const newContent = `${beforeContent}\n${upgradeNotes}\n${afterContent}`
256
        .split('\n')
257
        .filter((line, index, lines) => {
258
            if (line === '' && lines[index - 1] === '') {
259
                // Remove multiple consecutive empty lines.
260
                return false;
261
            }
262
            return true;
263
        })
264
        .join('\n');
265
 
266
    return newContent;
267
};
268
 
269
/**
270
 * Update the notes for each component.
271
 */
272
const updateComponentNotes = (
273
    notes,
274
    version,
275
    notesFileName = 'UPGRADING.md',
276
    removeEmpty = false,
277
) => {
278
    return getAllComponents().map(async (component) => {
279
        logger.verbose(`Updating notes for ${component.name} into ${component.path}`);
280
        const fileName = joinPath(component.path, notesFileName);
281
 
282
        const existingContent = await getCurrentNotes(fileName);
283
 
284
        if (!existingContent) {
285
            if (!notes[component.value]) {
286
                // No existing notes, and no new notes to add.
287
                return;
288
            }
289
        } else {
290
            if (!notes[component.value]) {
291
                // There is existing content, but nothing to add.
292
                if (removeEmpty) {
293
                    logger.verbose(`Removing empty notes file ${fileName}`);
294
                    await unlink(fileName);
295
                }
296
                return;
297
            }
298
        }
299
 
300
        const componentNotes = notes[component.value];
301
        let upgradeNotes = `## ${version}\n\n`;
302
        upgradeNotes += getNotesForComponent(componentNotes, 3);
303
 
304
        if (existingContent) {
305
            await writeFile(fileName, getUpdatedNotes(existingContent, upgradeNotes));
306
        } else {
307
            await writeFile(
308
                fileName,
309
                `# ${component.name} Upgrade notes\n\n${upgradeNotes}`,
310
            );
311
        }
312
    });
313
}
314
 
315
/**
316
 * Generate the upgrade notes for a new release.
317
 *
318
 * @param {string|undefined} version
319
 * @param {Object} options
320
 * @param {boolean} options.generateUpgradeNotes
321
 * @param {boolean} options.deleteNotes
322
 * @returns {Promise<void>}
323
 */
324
export default async (version, options = {}) => {
325
    const notes = await getCombinedNotesByComponent();
326
 
327
    if (Object.keys(notes).length === 0) {
328
        logger.warn('No notes to generate');
329
        return;
330
    }
331
 
332
    if (!version) {
333
        version = await getCurrentVersion();
334
    }
335
 
336
    // Generate the upgrade notes for this release.
337
    // We have
338
    // - a title with the release name
339
    // - the change types
340
    // - which contain the components
341
    // - which document each change
342
    let upgradeNotes = `## ${version}\n\n`;
343
 
344
    Object.entries(notes).forEach(([component, types]) => {
345
        upgradeNotes += `### ${component}\n\n`;
346
        upgradeNotes += getNotesForComponent(types, 4);
347
    });
348
 
349
    await Promise.all([
350
        createCurrentSummary(upgradeNotes),
351
        ...updateComponentNotes(notes, version, 'UPGRADING-CURRENT.md', true),
352
    ]);
353
    if (options.generateUpgradeNotes) {
354
        await Promise.all(updateComponentNotes(notes, version));
355
        await updateUpgradeNotes(upgradeNotes, options);
356
    }
357
};