|
| 1 | +# The sections to be included in the release notes |
| 2 | +const SECTIONS = [ |
| 3 | + [label, h2, h3]; |
| 4 | + ["notes:breaking-changes", "Breaking changes", "Other breaking changes"] |
| 5 | + ["notes:additions", "Additions", "Other additions"] |
| 6 | + ["notes:deprecations", "Deprecations", "Other deprecations"] |
| 7 | + ["notes:removals", "Removals", "Other removals"] |
| 8 | + ["notes:other", "Other changes", "Additional changes"] |
| 9 | + ["notes:fixes", "Bug fixes", "Other fixes"] |
| 10 | + ["notes:mention", null, null] |
| 11 | +] |
| 12 | + |
| 13 | +use notice.nu * |
| 14 | +use util.nu * |
| 15 | + |
| 16 | +# Attempt to extract the "Release notes summary" section from a PR. |
| 17 | +# |
| 18 | +# Multiple checks are done to ensure that each PR has a valid release notes summary. |
| 19 | +# If any issues are detected, a "notices" column with additional information is added. |
| 20 | +export def get-release-notes []: record -> record { |
| 21 | + mut pr = $in |
| 22 | + |
| 23 | + let has_ready_label = "notes:ready" in $pr.labels.name |
| 24 | + let sections = $SECTIONS | where label in $pr.labels.name |
| 25 | + let hall_of_fame = $SECTIONS | where label == "notes:mention" | only |
| 26 | + |
| 27 | + # Extract the notes section |
| 28 | + mut notes = if "## Release notes summary" in $pr.body { |
| 29 | + $pr.body | extract-notes |
| 30 | + } else if $has_ready_label { |
| 31 | + # If no release notes summary exists but ready label is set, treat as empty |
| 32 | + $pr = $pr | add-notice warning "no release notes section but notes:ready label" |
| 33 | + "" |
| 34 | + } else { |
| 35 | + return ($pr | add-notice error "no release notes section") |
| 36 | + } |
| 37 | + |
| 38 | + # Check for empty notes section |
| 39 | + if ($notes | is-empty-keyword) { |
| 40 | + if ($sections | where label != "notes:mention" | is-not-empty) { |
| 41 | + return ($pr | add-notice error "empty summary has a category other than Hall of Fame") |
| 42 | + } |
| 43 | + |
| 44 | + if ($notes | is-empty) and not $has_ready_label { |
| 45 | + $pr = $pr | add-notice warning "empty release notes section and no explicit label" |
| 46 | + } |
| 47 | + |
| 48 | + $pr = $pr | insert section $hall_of_fame |
| 49 | + $pr = $pr | insert notes ($pr.title | clean-title) |
| 50 | + return $pr |
| 51 | + } |
| 52 | + |
| 53 | + # If the notes section isn't empty, make sure we have the ready label |
| 54 | + if not $has_ready_label { |
| 55 | + return ($pr | add-notice error $"no notes:ready label") |
| 56 | + } |
| 57 | + |
| 58 | + # Check that exactly one category is selected |
| 59 | + let section = if ($sections | is-empty) { |
| 60 | + $pr = $pr | add-notice info "no explicit release notes category selected (defaults to Hall of Fame)" |
| 61 | + $hall_of_fame |
| 62 | + } else if ($sections | length) > 1 { |
| 63 | + return ($pr | add-notice error "multiple release notes categories selected") |
| 64 | + } else { |
| 65 | + $sections | only |
| 66 | + } |
| 67 | + |
| 68 | + # Add section to PR |
| 69 | + $pr = $pr | insert section $section |
| 70 | + |
| 71 | + let lines = $notes | lines | length |
| 72 | + if $section.label == "notes:mention" and ($lines > 1) { |
| 73 | + return ($pr | add-notice error "multi-line summaries in Hall of Fame section") |
| 74 | + } |
| 75 | + |
| 76 | + # Add PR title as default heading for multi-line summaries |
| 77 | + if $lines > 1 and not ($notes starts-with "###") { |
| 78 | + $pr = $pr | add-notice info "multi-line summaries with no explicit title (using PR title as heading title)" |
| 79 | + $notes = "### " + ($pr.title | clean-title) ++ (char nl) ++ $notes |
| 80 | + } |
| 81 | + |
| 82 | + # Check for suspiciously short release notes section |
| 83 | + if ($notes | split words | length) < 10 { |
| 84 | + $pr = $pr | add-notice warning "release notes section that is less than 10 words" |
| 85 | + } |
| 86 | + |
| 87 | + $pr | insert notes $notes |
| 88 | +} |
| 89 | + |
| 90 | +# Extracts the "Release notes summary" section of the PR description |
| 91 | +export def extract-notes []: string -> string { |
| 92 | + lines |
| 93 | + # skip until release notes heading |
| 94 | + | skip until { $in starts-with "## Release notes summary" } |
| 95 | + # this should already have been checked |
| 96 | + | if ($in | is-empty) { assert false } else {} |
| 97 | + | skip 1 # remove header |
| 98 | + # extract until next heading |
| 99 | + | take until { |
| 100 | + $in starts-with "# " or $in starts-with "## " or $in starts-with "---" |
| 101 | + } |
| 102 | + | str join (char nl) |
| 103 | + # remove HTML comments |
| 104 | + | str replace -amr '<!--\O*?-->' '' |
| 105 | + | str trim |
| 106 | +} |
| 107 | + |
| 108 | +# Generate the release notes from the list of PRs. |
| 109 | +export def generate-notes [version: string]: table -> string { |
| 110 | + let prs = $in |
| 111 | + |
| 112 | + const template_path = path self "template.md" |
| 113 | + let template = open $template_path |
| 114 | + let arguments = { |
| 115 | + # chop off the `v` in the version |
| 116 | + version: ($version | str substring 1..), |
| 117 | + changes: ($prs | generate-changes-section), |
| 118 | + hall_of_fame: ($prs | generate-hall-of-fame) |
| 119 | + changelog: (generate-full-changelog $version) |
| 120 | + } |
| 121 | + |
| 122 | + $arguments | format pattern $template |
| 123 | +} |
| 124 | + |
| 125 | +# Generate the "Changes" section of the release notes. |
| 126 | +export def generate-changes-section []: table -> string { |
| 127 | + group-by --to-table section.label |
| 128 | + | rename section prs |
| 129 | + # sort sections in order of appearance in table |
| 130 | + | sort-by {|i| $SECTIONS | enumerate | where item.label == $i.section | only } |
| 131 | + # Hall of Fame is handled separately |
| 132 | + | where section != "notes:mention" |
| 133 | + | each { generate-section } |
| 134 | + | str join (char nl) |
| 135 | +} |
| 136 | + |
| 137 | +# Generate a subsection of the "Changes" section of the release notes. |
| 138 | +export def generate-section []: record<section: string, prs: table> -> string { |
| 139 | + let prs = $in.prs |
| 140 | + let section = $prs.0.section |
| 141 | + |
| 142 | + mut body = [] |
| 143 | + let multiline = $prs | where ($it.notes | lines | length) > 1 |
| 144 | + let bullet = $prs | where ($it.notes | lines | length) == 1 |
| 145 | + |
| 146 | + # Add header |
| 147 | + $body ++= [$"## ($section.h2)"] |
| 148 | + |
| 149 | + # Add multi-line summaries |
| 150 | + $body ++= $multiline.notes |
| 151 | + |
| 152 | + # Add single-line summaries |
| 153 | + if ($multiline | is-not-empty) { |
| 154 | + $body ++= [$"### ($section.h3)"] |
| 155 | + } |
| 156 | + $body ++= $bullet | each {|pr| "* " ++ $pr.notes ++ $" \(($pr | pr-link)\)" } |
| 157 | + |
| 158 | + $body | str join (char nl) |
| 159 | +} |
| 160 | + |
| 161 | +# Generate the "Hall of Fame" section of the release notes. |
| 162 | +export def generate-hall-of-fame []: table -> string { |
| 163 | + where section.label == "notes:mention" |
| 164 | + # If the PR has no notes, use the title |
| 165 | + | update notes {|pr| default -e $pr.title } |
| 166 | + | update author { md-link $'@($in.login)' $'https://github.com/($in.login)' } |
| 167 | + | insert link { pr-link } |
| 168 | + | select author notes link |
| 169 | + | rename -c {notes: change} |
| 170 | + | to md |
| 171 | + | escape-tag |
| 172 | +} |
| 173 | + |
| 174 | +# Generate the "Full changelog" section of the release notes. |
| 175 | +export def generate-full-changelog [version: string]: nothing -> string { |
| 176 | + list-prs --milestone=$version |
| 177 | + | pr-table |
| 178 | +} |
0 commit comments