|
18 | 18 | # |
19 | 19 |
|
20 | 20 | from datetime import datetime |
| 21 | +import os.path |
21 | 22 | import re |
| 23 | +from typing import Optional |
22 | 24 |
|
| 25 | +from docutils import nodes |
| 26 | +from docutils import statemachine |
| 27 | +from docutils.parsers import rst |
| 28 | +import dulwich.repo |
23 | 29 | 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 |
24 | 36 |
|
25 | 37 |
|
26 | 38 | # from setuptools-scm |
@@ -282,7 +294,7 @@ def _skip(self, word): |
282 | 294 | # Latex figure (float) alignment |
283 | 295 | # |
284 | 296 | # 'figure_align': 'htbp', |
285 | | -} |
| 297 | +} # type: dict[str, str] |
286 | 298 |
|
287 | 299 | # Grouping the document tree into LaTeX files. List of tuples |
288 | 300 | # (source start file, target name, title, |
@@ -361,3 +373,261 @@ def _skip(self, word): |
361 | 373 | # If true, do not generate a @detailmenu in the "Top" node's menu. |
362 | 374 | # |
363 | 375 | # 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 |
0 commit comments