##// END OF EJS Templates
releasenotes: command to manage release notes files...
Gregory Szorc -
r32778:91e355a0 default
parent child Browse files
Show More
@@ -0,0 +1,429
1 # Copyright 2017-present Gregory Szorc <gregory.szorc@gmail.com>
2 #
3 # This software may be used and distributed according to the terms of the
4 # GNU General Public License version 2 or any later version.
5
6 """generate release notes from commit messages (EXPERIMENTAL)
7
8 It is common to maintain files detailing changes in a project between
9 releases. Maintaining these files can be difficult and time consuming.
10 The :hg:`releasenotes` command provided by this extension makes the
11 process simpler by automating it.
12 """
13
14 from __future__ import absolute_import
15
16 import errno
17 import re
18 import sys
19 import textwrap
20
21 from mercurial.i18n import _
22 from mercurial import (
23 error,
24 minirst,
25 registrar,
26 scmutil,
27 )
28
29 cmdtable = {}
30 command = registrar.command(cmdtable)
31
32 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
33 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
34 # be specifying the version(s) of Mercurial they are tested with, or
35 # leave the attribute unspecified.
36 testedwith = 'ships-with-hg-core'
37
38 DEFAULT_SECTIONS = [
39 ('feature', _('New Features')),
40 ('bc', _('Backwards Compatibility Changes')),
41 ('fix', _('Bug Fixes')),
42 ('perf', _('Performance Improvements')),
43 ('api', _('API Changes')),
44 ]
45
46 RE_DIRECTIVE = re.compile('^\.\. ([a-zA-Z0-9_]+)::\s*([^$]+)?$')
47
48 BULLET_SECTION = _('Other Changes')
49
50 class parsedreleasenotes(object):
51 def __init__(self):
52 self.sections = {}
53
54 def __contains__(self, section):
55 return section in self.sections
56
57 def __iter__(self):
58 return iter(sorted(self.sections))
59
60 def addtitleditem(self, section, title, paragraphs):
61 """Add a titled release note entry."""
62 self.sections.setdefault(section, ([], []))
63 self.sections[section][0].append((title, paragraphs))
64
65 def addnontitleditem(self, section, paragraphs):
66 """Adds a non-titled release note entry.
67
68 Will be rendered as a bullet point.
69 """
70 self.sections.setdefault(section, ([], []))
71 self.sections[section][1].append(paragraphs)
72
73 def titledforsection(self, section):
74 """Returns titled entries in a section.
75
76 Returns a list of (title, paragraphs) tuples describing sub-sections.
77 """
78 return self.sections.get(section, ([], []))[0]
79
80 def nontitledforsection(self, section):
81 """Returns non-titled, bulleted paragraphs in a section."""
82 return self.sections.get(section, ([], []))[1]
83
84 def hastitledinsection(self, section, title):
85 return any(t[0] == title for t in self.titledforsection(section))
86
87 def merge(self, ui, other):
88 """Merge another instance into this one.
89
90 This is used to combine multiple sources of release notes together.
91 """
92 for section in other:
93 for title, paragraphs in other.titledforsection(section):
94 if self.hastitledinsection(section, title):
95 # TODO prompt for resolution if different and running in
96 # interactive mode.
97 ui.write(_('%s already exists in %s section; ignoring\n') %
98 (title, section))
99 continue
100
101 # TODO perform similarity comparison and try to match against
102 # existing.
103 self.addtitleditem(section, title, paragraphs)
104
105 for paragraphs in other.nontitledforsection(section):
106 if paragraphs in self.nontitledforsection(section):
107 continue
108
109 # TODO perform similarily comparison and try to match against
110 # existing.
111 self.addnontitleditem(section, paragraphs)
112
113 class releasenotessections(object):
114 def __init__(self, ui):
115 # TODO support defining custom sections from config.
116 self._sections = list(DEFAULT_SECTIONS)
117
118 def __iter__(self):
119 return iter(self._sections)
120
121 def names(self):
122 return [t[0] for t in self._sections]
123
124 def sectionfromtitle(self, title):
125 for name, value in self._sections:
126 if value == title:
127 return name
128
129 return None
130
131 def parsenotesfromrevisions(repo, directives, revs):
132 notes = parsedreleasenotes()
133
134 for rev in revs:
135 ctx = repo[rev]
136
137 blocks, pruned = minirst.parse(ctx.description(),
138 admonitions=directives)
139
140 for i, block in enumerate(blocks):
141 if block['type'] != 'admonition':
142 continue
143
144 directive = block['admonitiontitle']
145 title = block['lines'][0].strip() if block['lines'] else None
146
147 if i + 1 == len(blocks):
148 raise error.Abort(_('release notes directive %s lacks content')
149 % directive)
150
151 # Now search ahead and find all paragraphs attached to this
152 # admonition.
153 paragraphs = []
154 for j in range(i + 1, len(blocks)):
155 pblock = blocks[j]
156
157 # Margin blocks may appear between paragraphs. Ignore them.
158 if pblock['type'] == 'margin':
159 continue
160
161 if pblock['type'] != 'paragraph':
162 raise error.Abort(_('unexpected block in release notes '
163 'directive %s') % directive)
164
165 if pblock['indent'] > 0:
166 paragraphs.append(pblock['lines'])
167 else:
168 break
169
170 # TODO consider using title as paragraph for more concise notes.
171 if not paragraphs:
172 raise error.Abort(_('could not find content for release note '
173 '%s') % directive)
174
175 if title:
176 notes.addtitleditem(directive, title, paragraphs)
177 else:
178 notes.addnontitleditem(directive, paragraphs)
179
180 return notes
181
182 def parsereleasenotesfile(sections, text):
183 """Parse text content containing generated release notes."""
184 notes = parsedreleasenotes()
185
186 blocks = minirst.parse(text)[0]
187
188 def gatherparagraphs(offset):
189 paragraphs = []
190
191 for i in range(offset + 1, len(blocks)):
192 block = blocks[i]
193
194 if block['type'] == 'margin':
195 continue
196 elif block['type'] == 'section':
197 break
198 elif block['type'] == 'bullet':
199 if block['indent'] != 0:
200 raise error.Abort(_('indented bullet lists not supported'))
201
202 lines = [l[1:].strip() for l in block['lines']]
203 paragraphs.append(lines)
204 continue
205 elif block['type'] != 'paragraph':
206 raise error.Abort(_('unexpected block type in release notes: '
207 '%s') % block['type'])
208
209 paragraphs.append(block['lines'])
210
211 return paragraphs
212
213 currentsection = None
214 for i, block in enumerate(blocks):
215 if block['type'] != 'section':
216 continue
217
218 title = block['lines'][0]
219
220 # TODO the parsing around paragraphs and bullet points needs some
221 # work.
222 if block['underline'] == '=': # main section
223 name = sections.sectionfromtitle(title)
224 if not name:
225 raise error.Abort(_('unknown release notes section: %s') %
226 title)
227
228 currentsection = name
229 paragraphs = gatherparagraphs(i)
230 if paragraphs:
231 notes.addnontitleditem(currentsection, paragraphs)
232
233 elif block['underline'] == '-': # sub-section
234 paragraphs = gatherparagraphs(i)
235
236 if title == BULLET_SECTION:
237 notes.addnontitleditem(currentsection, paragraphs)
238 else:
239 notes.addtitleditem(currentsection, title, paragraphs)
240 else:
241 raise error.Abort(_('unsupported section type for %s') % title)
242
243 return notes
244
245 def serializenotes(sections, notes):
246 """Serialize release notes from parsed fragments and notes.
247
248 This function essentially takes the output of ``parsenotesfromrevisions()``
249 and ``parserelnotesfile()`` and produces output combining the 2.
250 """
251 lines = []
252
253 for sectionname, sectiontitle in sections:
254 if sectionname not in notes:
255 continue
256
257 lines.append(sectiontitle)
258 lines.append('=' * len(sectiontitle))
259 lines.append('')
260
261 # First pass to emit sub-sections.
262 for title, paragraphs in notes.titledforsection(sectionname):
263 lines.append(title)
264 lines.append('-' * len(title))
265 lines.append('')
266
267 wrapper = textwrap.TextWrapper(width=78)
268 for i, para in enumerate(paragraphs):
269 if i:
270 lines.append('')
271 lines.extend(wrapper.wrap(' '.join(para)))
272
273 lines.append('')
274
275 # Second pass to emit bullet list items.
276
277 # If the section has titled and non-titled items, we can't
278 # simply emit the bullet list because it would appear to come
279 # from the last title/section. So, we emit a new sub-section
280 # for the non-titled items.
281 nontitled = notes.nontitledforsection(sectionname)
282 if notes.titledforsection(sectionname) and nontitled:
283 # TODO make configurable.
284 lines.append(BULLET_SECTION)
285 lines.append('-' * len(BULLET_SECTION))
286 lines.append('')
287
288 for paragraphs in nontitled:
289 wrapper = textwrap.TextWrapper(initial_indent='* ',
290 subsequent_indent=' ',
291 width=78)
292 lines.extend(wrapper.wrap(' '.join(paragraphs[0])))
293
294 wrapper = textwrap.TextWrapper(initial_indent=' ',
295 subsequent_indent=' ',
296 width=78)
297 for para in paragraphs[1:]:
298 lines.append('')
299 lines.extend(wrapper.wrap(' '.join(para)))
300
301 lines.append('')
302
303 if lines[-1]:
304 lines.append('')
305
306 return '\n'.join(lines)
307
308 @command('releasenotes',
309 [('r', 'rev', '', _('revisions to process for release notes'), _('REV'))],
310 _('[-r REV] FILE'))
311 def releasenotes(ui, repo, file_, rev=None):
312 """parse release notes from commit messages into an output file
313
314 Given an output file and set of revisions, this command will parse commit
315 messages for release notes then add them to the output file.
316
317 Release notes are defined in commit messages as ReStructuredText
318 directives. These have the form::
319
320 .. directive:: title
321
322 content
323
324 Each ``directive`` maps to an output section in a generated release notes
325 file, which itself is ReStructuredText. For example, the ``.. feature::``
326 directive would map to a ``New Features`` section.
327
328 Release note directives can be either short-form or long-form. In short-
329 form, ``title`` is omitted and the release note is rendered as a bullet
330 list. In long form, a sub-section with the title ``title`` is added to the
331 section.
332
333 The ``FILE`` argument controls the output file to write gathered release
334 notes to. The format of the file is::
335
336 Section 1
337 =========
338
339 ...
340
341 Section 2
342 =========
343
344 ...
345
346 Only sections with defined release notes are emitted.
347
348 If a section only has short-form notes, it will consist of bullet list::
349
350 Section
351 =======
352
353 * Release note 1
354 * Release note 2
355
356 If a section has long-form notes, sub-sections will be emitted::
357
358 Section
359 =======
360
361 Note 1 Title
362 ------------
363
364 Description of the first long-form note.
365
366 Note 2 Title
367 ------------
368
369 Description of the second long-form note.
370
371 If the ``FILE`` argument points to an existing file, that file will be
372 parsed for release notes having the format that would be generated by this
373 command. The notes from the processed commit messages will be *merged*
374 into this parsed set.
375
376 During release notes merging:
377
378 * Duplicate items are automatically ignored
379 * Items that are different are automatically ignored if the similarity is
380 greater than a threshold.
381
382 This means that the release notes file can be updated independently from
383 this command and changes should not be lost when running this command on
384 that file. A particular use case for this is to tweak the wording of a
385 release note after it has been added to the release notes file.
386 """
387 sections = releasenotessections(ui)
388
389 revs = scmutil.revrange(repo, [rev or 'not public()'])
390 incoming = parsenotesfromrevisions(repo, sections.names(), revs)
391
392 try:
393 with open(file_, 'rb') as fh:
394 notes = parsereleasenotesfile(sections, fh.read())
395 except IOError as e:
396 if e.errno != errno.ENOENT:
397 raise
398
399 notes = parsedreleasenotes()
400
401 notes.merge(ui, incoming)
402
403 with open(file_, 'wb') as fh:
404 fh.write(serializenotes(sections, notes))
405
406 @command('debugparsereleasenotes', norepo=True)
407 def debugparsereleasenotes(ui, path):
408 """parse release notes and print resulting data structure"""
409 if path == '-':
410 text = sys.stdin.read()
411 else:
412 with open(path, 'rb') as fh:
413 text = fh.read()
414
415 sections = releasenotessections(ui)
416
417 notes = parsereleasenotesfile(sections, text)
418
419 for section in notes:
420 ui.write(_('section: %s\n') % section)
421 for title, paragraphs in notes.titledforsection(section):
422 ui.write(_(' subsection: %s\n') % title)
423 for para in paragraphs:
424 ui.write(_(' paragraph: %s\n') % ' '.join(para))
425
426 for paragraphs in notes.nontitledforsection(section):
427 ui.write(_(' bullet point:\n'))
428 for para in paragraphs:
429 ui.write(_(' paragraph: %s\n') % ' '.join(para))
@@ -0,0 +1,256
1 $ cat >> $HGRCPATH << EOF
2 > [extensions]
3 > releasenotes=
4 > EOF
5
6 $ hg init simple-repo
7 $ cd simple-repo
8
9 A fix with a single line results in a bullet point in the appropriate section
10
11 $ touch fix1
12 $ hg -q commit -A -l - << EOF
13 > single line fix
14 >
15 > .. fix::
16 >
17 > Simple fix with a single line content entry.
18 > EOF
19
20 $ hg releasenotes -r . $TESTTMP/relnotes-single-line
21
22 $ cat $TESTTMP/relnotes-single-line
23 Bug Fixes
24 =========
25
26 * Simple fix with a single line content entry.
27
28 A fix with multiple lines is handled correctly
29
30 $ touch fix2
31 $ hg -q commit -A -l - << EOF
32 > multi line fix
33 >
34 > .. fix::
35 >
36 > First line of fix entry.
37 > A line after it without a space.
38 >
39 > A new paragraph in the fix entry. And this is a really long line. It goes on for a while.
40 > And it wraps around to a new paragraph.
41 > EOF
42
43 $ hg releasenotes -r . $TESTTMP/relnotes-multi-line
44 $ cat $TESTTMP/relnotes-multi-line
45 Bug Fixes
46 =========
47
48 * First line of fix entry. A line after it without a space.
49
50 A new paragraph in the fix entry. And this is a really long line. It goes on
51 for a while. And it wraps around to a new paragraph.
52
53 A release note with a title results in a sub-section being written
54
55 $ touch fix3
56 $ hg -q commit -A -l - << EOF
57 > fix with title
58 >
59 > .. fix:: Fix Title
60 >
61 > First line of fix with title.
62 >
63 > Another paragraph of fix with title. But this is a paragraph
64 > with multiple lines.
65 > EOF
66
67 $ hg releasenotes -r . $TESTTMP/relnotes-fix-with-title
68 $ cat $TESTTMP/relnotes-fix-with-title
69 Bug Fixes
70 =========
71
72 Fix Title
73 ---------
74
75 First line of fix with title.
76
77 Another paragraph of fix with title. But this is a paragraph with multiple
78 lines.
79
80 $ cd ..
81
82 Formatting of multiple bullet points works
83
84 $ hg init multiple-bullets
85 $ cd multiple-bullets
86 $ touch fix1
87 $ hg -q commit -A -l - << EOF
88 > commit 1
89 >
90 > .. fix::
91 >
92 > first fix
93 > EOF
94
95 $ touch fix2
96 $ hg -q commit -A -l - << EOF
97 > commit 2
98 >
99 > .. fix::
100 >
101 > second fix
102 >
103 > Second paragraph of second fix.
104 > EOF
105
106 $ touch fix3
107 $ hg -q commit -A -l - << EOF
108 > commit 3
109 >
110 > .. fix::
111 >
112 > third fix
113 > EOF
114
115 $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-bullets
116 $ cat $TESTTMP/relnotes-multiple-bullets
117 Bug Fixes
118 =========
119
120 * first fix
121
122 * second fix
123
124 Second paragraph of second fix.
125
126 * third fix
127
128 $ cd ..
129
130 Formatting of multiple sections works
131
132 $ hg init multiple-sections
133 $ cd multiple-sections
134 $ touch fix1
135 $ hg -q commit -A -l - << EOF
136 > commit 1
137 >
138 > .. fix::
139 >
140 > first fix
141 > EOF
142
143 $ touch feature1
144 $ hg -q commit -A -l - << EOF
145 > commit 2
146 >
147 > .. feature::
148 >
149 > description of the new feature
150 > EOF
151
152 $ touch fix2
153 $ hg -q commit -A -l - << EOF
154 > commit 3
155 >
156 > .. fix::
157 >
158 > second fix
159 > EOF
160
161 $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-sections
162 $ cat $TESTTMP/relnotes-multiple-sections
163 New Features
164 ============
165
166 * description of the new feature
167
168 Bug Fixes
169 =========
170
171 * first fix
172
173 * second fix
174
175 $ cd ..
176
177 Section with subsections and bullets
178
179 $ hg init multiple-subsections
180 $ cd multiple-subsections
181
182 $ touch fix1
183 $ hg -q commit -A -l - << EOF
184 > commit 1
185 >
186 > .. fix:: Title of First Fix
187 >
188 > First paragraph of first fix.
189 >
190 > Second paragraph of first fix.
191 > EOF
192
193 $ touch fix2
194 $ hg -q commit -A -l - << EOF
195 > commit 2
196 >
197 > .. fix:: Title of Second Fix
198 >
199 > First paragraph of second fix.
200 >
201 > Second paragraph of second fix.
202 > EOF
203
204 $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-subsections
205 $ cat $TESTTMP/relnotes-multiple-subsections
206 Bug Fixes
207 =========
208
209 Title of First Fix
210 ------------------
211
212 First paragraph of first fix.
213
214 Second paragraph of first fix.
215
216 Title of Second Fix
217 -------------------
218
219 First paragraph of second fix.
220
221 Second paragraph of second fix.
222
223 Now add bullet points to sections having sub-sections
224
225 $ touch fix3
226 $ hg -q commit -A -l - << EOF
227 > commit 3
228 >
229 > .. fix::
230 >
231 > Short summary of fix 3
232 > EOF
233
234 $ hg releasenotes -r 'all()' $TESTTMP/relnotes-multiple-subsections-with-bullets
235 $ cat $TESTTMP/relnotes-multiple-subsections-with-bullets
236 Bug Fixes
237 =========
238
239 Title of First Fix
240 ------------------
241
242 First paragraph of first fix.
243
244 Second paragraph of first fix.
245
246 Title of Second Fix
247 -------------------
248
249 First paragraph of second fix.
250
251 Second paragraph of second fix.
252
253 Other Changes
254 -------------
255
256 * Short summary of fix 3
@@ -0,0 +1,112
1 $ cat >> $HGRCPATH << EOF
2 > [extensions]
3 > releasenotes=
4 > EOF
5
6 $ hg init simple-repo
7 $ cd simple-repo
8
9 A fix directive from commit message is added to release notes
10
11 $ touch fix1
12 $ hg -q commit -A -l - << EOF
13 > commit 1
14 >
15 > .. fix::
16 >
17 > Fix from commit message.
18 > EOF
19
20 $ cat >> $TESTTMP/single-fix-bullet << EOF
21 > Bug Fixes
22 > =========
23 >
24 > * Fix from release notes.
25 > EOF
26
27 $ hg releasenotes -r . $TESTTMP/single-fix-bullet
28
29 $ cat $TESTTMP/single-fix-bullet
30 Bug Fixes
31 =========
32
33 * Fix from release notes.
34
35 * Fix from commit message.
36
37 Processing again will no-op
38 TODO this is buggy
39
40 $ hg releasenotes -r . $TESTTMP/single-fix-bullet
41
42 $ cat $TESTTMP/single-fix-bullet
43 Bug Fixes
44 =========
45
46 * Fix from release notes.
47
48 Fix from commit message.
49
50 * Fix from commit message.
51
52 $ cd ..
53
54 Sections are unioned
55
56 $ hg init subsections
57 $ cd subsections
58 $ touch fix1
59 $ hg -q commit -A -l - << EOF
60 > Commit 1
61 >
62 > .. feature:: Commit Message Feature
63 >
64 > This describes a feature from a commit message.
65 > EOF
66
67 $ cat >> $TESTTMP/single-feature-section << EOF
68 > New Features
69 > ============
70 >
71 > Notes Feature
72 > -------------
73 >
74 > This describes a feature from a release notes file.
75 > EOF
76
77 $ hg releasenotes -r . $TESTTMP/single-feature-section
78
79 $ cat $TESTTMP/single-feature-section
80 New Features
81 ============
82
83 Notes Feature
84 -------------
85
86 This describes a feature from a release notes file.
87
88 Commit Message Feature
89 ----------------------
90
91 This describes a feature from a commit message.
92
93 Doing it again won't add another section
94
95 $ hg releasenotes -r . $TESTTMP/single-feature-section
96 Commit Message Feature already exists in feature section; ignoring
97
98 $ cat $TESTTMP/single-feature-section
99 New Features
100 ============
101
102 Notes Feature
103 -------------
104
105 This describes a feature from a release notes file.
106
107 Commit Message Feature
108 ----------------------
109
110 This describes a feature from a commit message.
111
112 $ cd ..
@@ -0,0 +1,169
1 $ cat >> $HGRCPATH << EOF
2 > [extensions]
3 > releasenotes=
4 > EOF
5
6 Bullet point with a single item spanning a single line
7
8 $ hg debugparsereleasenotes - << EOF
9 > New Features
10 > ============
11 >
12 > * Bullet point item with a single line
13 > EOF
14 section: feature
15 bullet point:
16 paragraph: Bullet point item with a single line
17
18 Bullet point that spans multiple lines.
19
20 $ hg debugparsereleasenotes - << EOF
21 > New Features
22 > ============
23 >
24 > * Bullet point with a paragraph
25 > that spans multiple lines.
26 > EOF
27 section: feature
28 bullet point:
29 paragraph: Bullet point with a paragraph that spans multiple lines.
30
31 $ hg debugparsereleasenotes - << EOF
32 > New Features
33 > ============
34 >
35 > * Bullet point with a paragraph
36 > that spans multiple lines.
37 >
38 > And has an empty line between lines too.
39 > With a line cuddling that.
40 > EOF
41 section: feature
42 bullet point:
43 paragraph: Bullet point with a paragraph that spans multiple lines.
44 paragraph: And has an empty line between lines too. With a line cuddling that.
45
46 Multiple bullet points. With some entries being multiple lines.
47
48 $ hg debugparsereleasenotes - << EOF
49 > New Features
50 > ============
51 >
52 > * First bullet point. It has a single line.
53 >
54 > * Second bullet point.
55 > It consists of multiple lines.
56 >
57 > * Third bullet point. It has a single line.
58 > EOF
59 section: feature
60 bullet point:
61 paragraph: First bullet point. It has a single line.
62 paragraph: Second bullet point. It consists of multiple lines.
63 paragraph: Third bullet point. It has a single line.
64
65 Bullet point without newline between items
66
67 $ hg debugparsereleasenotes - << EOF
68 > New Features
69 > ============
70 >
71 > * First bullet point
72 > * Second bullet point
73 > And it has multiple lines
74 > * Third bullet point
75 > * Fourth bullet point
76 > EOF
77 section: feature
78 bullet point:
79 paragraph: First bullet point
80 paragraph: Second bullet point And it has multiple lines
81 paragraph: Third bullet point
82 paragraph: Fourth bullet point
83
84 Sub-section contents are read
85
86 $ hg debugparsereleasenotes - << EOF
87 > New Features
88 > ============
89 >
90 > First Feature
91 > -------------
92 >
93 > This is the first new feature that was implemented.
94 >
95 > And a second paragraph about it.
96 >
97 > Second Feature
98 > --------------
99 >
100 > This is the second new feature that was implemented.
101 >
102 > Paragraph two.
103 >
104 > Paragraph three.
105 > EOF
106 section: feature
107 subsection: First Feature
108 paragraph: This is the first new feature that was implemented.
109 paragraph: And a second paragraph about it.
110 subsection: Second Feature
111 paragraph: This is the second new feature that was implemented.
112 paragraph: Paragraph two.
113 paragraph: Paragraph three.
114
115 Multiple sections are read
116
117 $ hg debugparsereleasenotes - << EOF
118 > New Features
119 > ============
120 >
121 > * Feature 1
122 > * Feature 2
123 >
124 > Bug Fixes
125 > =========
126 >
127 > * Fix 1
128 > * Fix 2
129 > EOF
130 section: feature
131 bullet point:
132 paragraph: Feature 1
133 paragraph: Feature 2
134 section: fix
135 bullet point:
136 paragraph: Fix 1
137 paragraph: Fix 2
138
139 Mixed sub-sections and bullet list
140
141 $ hg debugparsereleasenotes - << EOF
142 > New Features
143 > ============
144 >
145 > Feature 1
146 > ---------
147 >
148 > Some words about the first feature.
149 >
150 > Feature 2
151 > ---------
152 >
153 > Some words about the second feature.
154 > That span multiple lines.
155 >
156 > Other Changes
157 > -------------
158 >
159 > * Bullet item 1
160 > * Bullet item 2
161 > EOF
162 section: feature
163 subsection: Feature 1
164 paragraph: Some words about the first feature.
165 subsection: Feature 2
166 paragraph: Some words about the second feature. That span multiple lines.
167 bullet point:
168 paragraph: Bullet item 1
169 paragraph: Bullet item 2
General Comments 0
You need to be logged in to leave comments. Login now