Skip to content

Commit 23b58ba

Browse files
132iklBahex
andauthored
Generate release notes from PR descriptions (#1160)
Co-authored-by: Bahex <[email protected]>
1 parent 38ee42c commit 23b58ba

File tree

10 files changed

+429
-127
lines changed

10 files changed

+429
-127
lines changed

make_release/notes/completions.nu

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const example_version = $"v0.((version).minor + 1).0"
2+
export const current_build_date = ((version).build_time | parse '{date} {_}').0.date
3+
4+
export def last-release-date []: nothing -> datetime {
5+
if $env.cached-var?.relase-date? == null {
6+
$env.cached-var.relase-date = (
7+
^gh release list
8+
--repo "nushell/nushell"
9+
--exclude-drafts --exclude-pre-releases
10+
--limit 1
11+
--json "createdAt"
12+
)
13+
| from json
14+
| $in.0.createdAt
15+
| into datetime
16+
| $in
17+
}
18+
$env.cached-var.relase-date
19+
}
20+
21+
export def "nu-complete version" [] { [$example_version] }
22+
export def "nu-complete date" [add?: duration = 0wk] {
23+
let date = last-release-date | $in + $add
24+
[{value: ($date | format date '%F') description: ($date | to text -n)}]
25+
}
26+
export def "nu-complete date current" [] { nu-complete date 0wk }
27+
export def "nu-complete date next" [] { nu-complete date 6wk }

make_release/release-note/create-pr.nu renamed to make_release/notes/create-pr.nu

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
#!/usr/bin/env nu
2-
31
use std log
42

3+
use completions.nu *
4+
use tools.nu release-notes
5+
56
def open-pr [
67
repo: path
78
remote: string
@@ -31,13 +32,10 @@ def clean [repo: path] {
3132
}
3233

3334
# open the release note PR interactively
34-
#
35-
# # Example
36-
# [this PR](https://github.com/nushell/nushell.github.io/pull/916) has been created with the script
37-
# > ./make_release/release-note/create-pr 0.81 2023-06-06
38-
def main [
39-
version: string # the version of the release, e.g. `0.80`
40-
date: datetime # the date of the upcoming release, e.g. `2023-05-16`
35+
@example "Create a PR for the next release" $"create-pr ($example_version) \(($current_build_date) + 6wk\)"
36+
export def main [
37+
version: string@"nu-complete version" # the version of the release
38+
date: datetime@"nu-complete date next" # the date of the upcoming release
4139
] {
4240
let repo = ($nu.temp-path | path join (random uuid))
4341
let branch = $"release-notes-($version)"
@@ -49,22 +47,19 @@ def main [
4947
let title = $"Release notes for `($version)`"
5048
let body = $"Please add your new features and breaking changes to the release notes
5149
by opening PRs against the `release-notes-($version)` branch.
52-
5350
## TODO
54-
- [ ] PRs that need to land before the release, e.g. [deprecations]\(https://github.com/nushell/nushell/labels/deprecation\) or [removals]\(https://github.com/nushell/nushell/pulls?q=is%3Apr+is%3Aopen+label%3Aremoval-after-deprecation\)
51+
- [ ] PRs that need to land before the release, e.g. [deprecations] or [removals]
5552
- [ ] add the full changelog
5653
- [ ] categorize each PR
57-
- [ ] write all the sections and complete all the `TODO`s"
54+
- [ ] write all the sections and complete all the `TODO`s
55+
[deprecations]: https://github.com/nushell/nushell/labels/deprecation
56+
[removals]: https://github.com/nushell/nushell/pulls?q=is%3Apr+is%3Aopen+label%3Aremoval-after-deprecation"
5857

59-
log info "creating release note from template"
60-
let release_note = $env.CURRENT_FILE
61-
| path dirname
62-
| path join "template.md"
63-
| open
64-
| str replace --all "{{VERSION}}" $version
58+
log info "generating release notes"
59+
let release_note = release-notes $version
6560

6661
log info $"branch: ($branch)"
67-
log info $"blog: ($blog_path | str replace $repo "" | path split | skip 1 | path join)"
62+
log info $"blog: ($blog_path | path relative-to $repo | path basename)"
6863
log info $"title: ($title)"
6964

7065
match (["yes" "no"] | input list --fuzzy "Inspect the release note document? ") {
@@ -76,48 +71,54 @@ by opening PRs against the `release-notes-($version)` branch.
7671
}
7772

7873
let temp_file = $nu.temp-path | path join $"(random uuid).md"
79-
$release_note | save --force $temp_file
74+
[
75+
"<!-- WARNING: Changes made to this file are NOT included in the PR -->"
76+
""
77+
$release_note
78+
] | to text | save --force $temp_file
8079
^$env.EDITOR $temp_file
8180
rm --recursive --force $temp_file
8281
},
83-
"no" | "" | _ => {},
82+
"no" | "" | _ => { }
8483
}
8584

8685
match (["no" "yes"] | input list --fuzzy "Open release note PR? ") {
87-
"yes" => {},
86+
"yes" => { },
8887
"no" | "" | _ => {
8988
log warning "aborting."
9089
return
91-
},
90+
}
9291
}
9392

9493
log info "setting up nushell.github.io repo"
95-
git clone https://github.com/nushell/nushell.github.io $repo --origin nushell --branch main --single-branch
96-
git -C $repo remote set-url nushell --push [email protected]:nushell/nushell.github.io.git
94+
^git clone https://github.com/nushell/nushell.github.io $repo --origin nushell --branch main --single-branch
95+
^git -C $repo remote set-url nushell --push [email protected]:nushell/nushell.github.io.git
9796

9897
log info "creating release branch"
99-
git -C $repo checkout -b $branch
98+
^git -C $repo checkout -b $branch
10099

101100
log info "writing release note"
102101
$release_note | save --force $blog_path
103102

104103
log info "committing release note"
105-
git -C $repo add $blog_path
106-
git -C $repo commit -m $"($title)\n\n($body)"
104+
^git -C $repo add $blog_path
105+
^git -C $repo commit -m $"($title)\n\n($body)"
107106

108107
log info "pushing release note to nushell"
109-
git -C $repo push nushell $branch
108+
^git -C $repo push nushell $branch
110109

111-
let out = (do -i { gh auth status } | complete)
110+
let out = (do -i { ^gh auth status } | complete)
112111
if $out.exit_code != 0 {
113112
clean $repo
114113

115114
let pr_url = $"https://github.com/nushell/nushell.github.io/compare/($branch)?expand=1"
116115
error make --unspanned {
117-
msg: ([
118-
$out.stderr
119-
$"please open the PR manually from a browser (ansi blue_underline)($pr_url)(ansi reset)"
120-
] | str join "\n")
116+
msg: (
117+
[
118+
$out.stderr
119+
$"please open the PR manually from a browser (ansi blue_underline)($pr_url)(ansi reset)"
120+
] | str join "\n"
121+
)
121122
}
122123
}
123124

make_release/notes/generate.nu

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
}

make_release/release-note/gh-release-excerpt.nu renamed to make_release/notes/gh-release-excerpt.nu

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
#!/usr/bin/env nu
2-
3-
41
# Prepare the GitHub release text
5-
def main [
2+
export def main [
63
versionname: string # The version we release now
74
bloglink: string # The link to the blogpost
85
date?: datetime # the date of the last release (default to 6 weeks ago, excluded)

make_release/notes/mod.nu

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export use tools.nu *
2+
export use gh-release-excerpt.nu
3+
export use create-pr.nu

make_release/notes/notice.nu

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const TYPES = [
2+
[type, color, rank];
3+
[info, (ansi default), 0]
4+
[warning, (ansi yellow), 1]
5+
[error, (ansi red), 2]
6+
]
7+
8+
# Add an entry to the "notices" field of a PR
9+
export def add-notice [type: string, message: string]: record -> record {
10+
upsert notices {
11+
append {type: $type, message: $message}
12+
}
13+
}
14+
15+
export def group-notices []: table -> table {
16+
let prs = $in
17+
18+
$prs
19+
| flatten -a notices
20+
| group-by --to-table type? message?
21+
| sort-by {|i| $TYPES | where type == $i.type | only rank } message
22+
}
23+
24+
# Print all of the notices associated with a PR
25+
export def display-notices []: table -> nothing {
26+
group-notices
27+
| each {|e|
28+
let color = $TYPES | where type == $e.type | only color
29+
let number = $e.items | length
30+
print $"($color)($number) PR\(s\) with ($e.message):"
31+
$e.items | each { format-pr | print $"- ($in)" }
32+
print ""
33+
}
34+
print -n (ansi reset)
35+
}

0 commit comments

Comments
 (0)