Skip to content

Commit 7555c50

Browse files
authored
chore(docs): add custom directive to dynamically generate release notes (#3368) (#3477) (#3479)
* chore(docs): add custom directive to dynamically generate release notes This new `.. ddtrace-release-notes::` directive will dynamically search for all release branches + earliest release version and generate the desired release notes * Update docs/conf.py (cherry picked from commit a357d8d) Co-authored-by: Brett Langdon <[email protected]> (cherry picked from commit d4e5260) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 129d8a7 commit 7555c50

File tree

4 files changed

+275
-64
lines changed

4 files changed

+275
-64
lines changed

docs/conf.py

Lines changed: 271 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,21 @@
1818
#
1919

2020
from datetime import datetime
21+
import os.path
2122
import re
23+
from typing import Optional
2224

25+
from docutils import nodes
26+
from docutils import statemachine
27+
from docutils.parsers import rst
28+
import dulwich.repo
2329
from enchant.tokenize import Filter
30+
from packaging.version import Version
31+
from reno import config
32+
from reno import formatter
33+
from reno import loader
34+
from sphinx.util import logging
35+
from sphinx.util.nodes import nested_parse_with_titles
2436

2537

2638
# from setuptools-scm
@@ -282,7 +294,7 @@ def _skip(self, word):
282294
# Latex figure (float) alignment
283295
#
284296
# 'figure_align': 'htbp',
285-
}
297+
} # type: dict[str, str]
286298

287299
# Grouping the document tree into LaTeX files. List of tuples
288300
# (source start file, target name, title,
@@ -361,3 +373,261 @@ def _skip(self, word):
361373
# If true, do not generate a @detailmenu in the "Top" node's menu.
362374
#
363375
# texinfo_no_detailmenu = False
376+
377+
378+
LOG = logging.getLogger(__name__)
379+
380+
381+
class DDTraceReleaseNotesDirective(rst.Directive):
382+
r"""
383+
Directive class to handle ``.. ddtrace-release-notes::`` directive.
384+
385+
This directive is used to generate up to date release notes from
386+
Reno notes in this repo.
387+
388+
This directive will dynamically search for all release branches that
389+
match ``^[\d]+\.[\d+]``, and generate the notes for each of those
390+
releases.
391+
392+
When generating notes for a given branch we will also search for the
393+
best "earliest version" to use for that branch. For example, on a new
394+
release branch with only prereleases we will resolve to the rc1 version
395+
for that release. If there are any non-prereleases for that branch we will
396+
resolve to the first non-rc release.
397+
"""
398+
399+
has_content = True
400+
401+
def __init__(self, *args, **kwargs):
402+
super(DDTraceReleaseNotesDirective, self).__init__(*args, **kwargs)
403+
404+
self._repo = dulwich.repo.Repo.discover(".")
405+
self._release_branch_pattern = re.compile(r"^[\d]+\.[\d]+")
406+
self._dev_branch_pattern = re.compile(r"^[\d]+\.x")
407+
408+
@property
409+
def _release_branches(self):
410+
# type: () -> list[tuple[Version, str]]
411+
r"""
412+
Helper to get a list of release branches for this repo.
413+
414+
A release branch exists under refs/remotes/origin/ and matches the pattern::
415+
416+
r"^[\d]+\.[\d]+"
417+
418+
The results are a list of parsed Versions from the branch name (to make sorting/
419+
comparing easier), along with the original ref name.
420+
"""
421+
versions = [] # type: list[tuple[Version, str]]
422+
for ref in self._repo.get_refs():
423+
# We get the ref as bytes from dulwich, convert to str
424+
ref = ref.decode()
425+
426+
# Ignore any refs that aren't for origin/
427+
if not ref.startswith("refs/remotes/origin/"):
428+
continue
429+
430+
# Get just the branch name omitting origin/
431+
# and make sure it matches our pattern
432+
_, _, version_str = ref.partition("refs/remotes/origin/")
433+
if not self._release_branch_pattern.match(version_str):
434+
continue
435+
436+
try:
437+
# All release branches should be parseable as a Version, even `0.10-dev` for example
438+
versions.append((Version(version_str), ref))
439+
except Exception:
440+
continue
441+
442+
# Sort them so the most recent version comes first
443+
# (1.12, 1.10, 1.0, 0.59, 0.58, ... 0.45, ... 0.5, 0.4)
444+
return sorted(versions, key=lambda e: e[0], reverse=True)
445+
446+
def _get_earliest_version(self, version):
447+
# type: (Version) -> Optional[str]
448+
"""
449+
Helper used to get the earliest release tag for a given version.
450+
451+
If there are only prerelease versions, return the first prerelease version.
452+
453+
If there exist any non-prerelease versions then return the first non-prerelease version.
454+
455+
If no release tags exist then return None
456+
"""
457+
# All release tags for this version should start like this
458+
version_prefix = "refs/tags/v{0}.{1}.".format(version.major, version.minor)
459+
460+
tag_versions = [] # type: list[tuple[Version, str]]
461+
for ref in self._repo.get_refs():
462+
ref = ref.decode()
463+
464+
# Make sure this matches the expected v{major}.{minor}. format
465+
if not ref.startswith(version_prefix):
466+
continue
467+
468+
# Parse as a Version object to make introspection and comparisons easier
469+
try:
470+
tag = ref[10:]
471+
tag_versions.append((Version(tag), tag))
472+
except Exception:
473+
pass
474+
475+
# No tags were found, exit early
476+
if not tag_versions:
477+
return None
478+
479+
# Sort the tags newest version. tag_versions[-1] should be the earliest version
480+
tag_versions = sorted(tag_versions, key=lambda e: e[0], reverse=True)
481+
482+
# Determine if there are only prereleases for this version
483+
is_prerelease = all([version.is_prerelease or version.is_devrelease for version, _ in tag_versions])
484+
485+
# There are official versions here, filter out the pre/dev releases
486+
if not is_prerelease:
487+
tag_versions = [
488+
(version, tag) for version, tag in tag_versions if not (version.is_prerelease or version.is_devrelease)
489+
]
490+
491+
# Return the oldest version
492+
if tag_versions:
493+
return tag_versions[-1][1]
494+
return None
495+
496+
def _get_commit_refs(self, commit_sha):
497+
# type: (bytes) -> list[bytes]
498+
"""Return a list of refs for the given commit sha"""
499+
return [ref for ref in self._repo.refs.keys() if self._repo.refs[ref] == commit_sha]
500+
501+
def _get_report_max_version(self):
502+
# type: () -> Optional[Version]
503+
"""Determine the max cutoff version to report for HEAD"""
504+
for entry in self._repo.get_walker():
505+
refs = self._get_commit_refs(entry.commit.id)
506+
if not refs:
507+
continue
508+
509+
for ref in refs:
510+
if not ref.startswith(b"refs/remotes/origin/"):
511+
continue
512+
513+
ref_str = ref[20:].decode()
514+
if self._release_branch_pattern.match(ref_str):
515+
v = Version(ref_str)
516+
return Version("{}.{}".format(v.major, v.minor + 1))
517+
elif self._dev_branch_pattern.match(ref_str):
518+
major, _, _ = ref_str.partition(".")
519+
return Version("{}.0.0".format(int(major) + 1))
520+
return None
521+
522+
def run(self):
523+
# type: () -> nodes.Node
524+
"""
525+
Run to generate the output from .. ddtrace-release-notes:: directive
526+
527+
528+
1. Determine the max version cutoff we need to report for
529+
530+
We determine this by traversing the git log until we
531+
find the first dev or release branch ref.
532+
533+
If we are generating for 1.x branch we will use 2.0 as the cutoff.
534+
535+
If we are generating for 0.60 branch we will use 0.61 as the cutoff.
536+
537+
We do this to ensure if we are generating notes for older versions
538+
we do no include all up to date release notes. Think releasing 0.57.2
539+
when there is 0.58.0, 0.59.0, 1.0.0, etc we only want notes for < 0.58.
540+
541+
2. Iterate through all release branches
542+
543+
A release branch is one that matches the ``^[0-9]+.[0-9]+``` pattern
544+
545+
Skip any that do not meet the max version cutoff.
546+
547+
3. Determine the earliest version to report for each release branch
548+
549+
If the release has only RC releases then use ``.0rc1`` as the earliest
550+
version. If there are non-RC releases then use ``.0`` version as the
551+
earliest.
552+
553+
We do this because we want reno to only report notes that are for that
554+
given release branch but it will collapse RC releases if there is a
555+
non-RC tag on that branch. So there isn't a consistent "earliest version"
556+
we can use for in-progress/dev branches as well as released branches.
557+
558+
559+
4. Generate a reno config for reporting and generate the notes for each branch
560+
"""
561+
# This is where we will aggregate the generated notes
562+
title = " ".join(self.content)
563+
result = statemachine.ViewList()
564+
565+
# Determine the max version we want to report for
566+
max_version = self._get_report_max_version()
567+
LOG.info("capping max report version to %r", max_version)
568+
569+
# For each release branch, starting with the newest
570+
for version, ref in self._release_branches:
571+
# If this version is equal to or greater than the max version we want to report for
572+
if max_version is not None and version >= max_version:
573+
LOG.info("skipping %s >= %s", version, max_version)
574+
continue
575+
576+
# Older versions did not have reno release notes
577+
# DEV: Reno will fail if we try to run on a branch with no notes
578+
if (version.major, version.minor) < (0, 44):
579+
LOG.info("skipping older version %s", version)
580+
continue
581+
582+
# Parse the branch name from the ref, we want origin/{major}.{minor}[-dev]
583+
_, _, branch = ref.partition("refs/remotes/")
584+
585+
# Determine the earliest release tag for this version
586+
earliest_version = self._get_earliest_version(version)
587+
if not earliest_version:
588+
LOG.info("no release tags found for %s", version)
589+
continue
590+
591+
# Setup reno config
592+
conf = config.Config(self._repo.path, "releasenotes")
593+
conf.override(
594+
branch=branch,
595+
collapse_pre_releases=True,
596+
stop_at_branch_base=True,
597+
earliest_version=earliest_version,
598+
)
599+
LOG.info(
600+
"scanning %s for %s release notes, stopping at %s",
601+
os.path.join(self._repo.path, "releasenotes/notes"),
602+
branch,
603+
earliest_version,
604+
)
605+
606+
# Generate the formatted RST
607+
with loader.Loader(conf) as ldr:
608+
versions = ldr.versions
609+
LOG.info("got versions %s", versions)
610+
text = formatter.format_report(
611+
ldr,
612+
conf,
613+
versions,
614+
title=title,
615+
branch=branch,
616+
)
617+
618+
source_name = "<%s %s>" % (__name__, branch or "current branch")
619+
for line_num, line in enumerate(text.splitlines(), 1):
620+
LOG.debug("%4d: %s", line_num, line)
621+
result.append(line, source_name, line_num)
622+
623+
# Generate the RST nodes to return for rendering
624+
node = nodes.section()
625+
node.document = self.state.document
626+
nested_parse_with_titles(self.state, result, node)
627+
return node.children
628+
629+
630+
def setup(app):
631+
app.add_directive("ddtrace-release-notes", DDTraceReleaseNotesDirective)
632+
metadata_dict = {"version": "1.0.0", "parallel_read_safe": True}
633+
return metadata_dict

docs/release_notes.rst

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -10,69 +10,8 @@ Release Notes
1010

1111
.. release-notes::
1212

13-
.. release-notes::
14-
:branch: 0.59
15-
:earliest-version: v0.59.0
1613

17-
.. release-notes::
18-
:branch: 0.58
19-
:earliest-version: v0.58.0
20-
21-
.. release-notes::
22-
:branch: 0.57
23-
:earliest-version: v0.57.0
24-
25-
.. release-notes::
26-
:branch: 0.56
27-
:earliest-version: v0.56.0
28-
29-
.. release-notes::
30-
:branch: 0.55
31-
:earliest-version: v0.55.0
32-
33-
.. release-notes::
34-
:branch: 0.54
35-
:earliest-version: v0.54.0
36-
37-
.. release-notes::
38-
:branch: 0.53
39-
:earliest-version: v0.53.0
40-
41-
.. release-notes::
42-
:branch: 0.52
43-
:earliest-version: v0.52.0
44-
45-
.. release-notes::
46-
:branch: 0.51
47-
:earliest-version: v0.51.0
48-
49-
.. release-notes::
50-
:branch: 0.50
51-
:earliest-version: v0.50.0
52-
53-
.. release-notes::
54-
:branch: 0.49
55-
:earliest-version: v0.49.0
56-
57-
.. release-notes::
58-
:branch: 0.48
59-
:earliest-version: v0.48.0
60-
61-
.. release-notes::
62-
:branch: 0.47
63-
:earliest-version: v0.47.0
64-
65-
.. release-notes::
66-
:branch: 0.46
67-
:earliest-version: v0.46.0
68-
69-
.. release-notes::
70-
:branch: 0.45
71-
:earliest-version: v0.45.0
72-
73-
.. release-notes::
74-
:branch: 0.44
75-
:earliest-version: v0.44.0
14+
.. ddtrace-release-notes::
7615

7716

7817
Prior Releases

mypy.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
[mypy]
22
files = ddtrace,
33
ddtrace/profiling/_build.pyx,
4-
ddtrace/profiling/exporter/pprof.pyx
4+
ddtrace/profiling/exporter/pprof.pyx,
5+
docs
56
# mypy thinks .pyx files are scripts and errors out if it finds multiple scripts
67
scripts_are_modules = true
78
show_error_codes = true

riotfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
131131
pkgs={
132132
"mypy": latest,
133133
"types-attrs": latest,
134+
"types-docutils": latest,
134135
"types-protobuf": latest,
135136
"types-setuptools": latest,
136137
"types-six": latest,

0 commit comments

Comments
 (0)