diff --git a/make_release/notes/completions.nu b/make_release/notes/completions.nu new file mode 100644 index 000000000..0f86ed355 --- /dev/null +++ b/make_release/notes/completions.nu @@ -0,0 +1,27 @@ +export const example_version = $"v0.((version).minor + 1).0" +export const current_build_date = ((version).build_time | parse '{date} {_}').0.date + +export def last-release-date []: nothing -> datetime { + if $env.cached-var?.relase-date? == null { + $env.cached-var.relase-date = ( + ^gh release list + --repo "nushell/nushell" + --exclude-drafts --exclude-pre-releases + --limit 1 + --json "createdAt" + ) + | from json + | $in.0.createdAt + | into datetime + | $in + } + $env.cached-var.relase-date +} + +export def "nu-complete version" [] { [$example_version] } +export def "nu-complete date" [add?: duration = 0wk] { + let date = last-release-date | $in + $add + [{value: ($date | format date '%F') description: ($date | to text -n)}] +} +export def "nu-complete date current" [] { nu-complete date 0wk } +export def "nu-complete date next" [] { nu-complete date 6wk } diff --git a/make_release/release-note/create-pr.nu b/make_release/notes/create-pr.nu old mode 100755 new mode 100644 similarity index 59% rename from make_release/release-note/create-pr.nu rename to make_release/notes/create-pr.nu index 6d258f563..4b7993cb4 --- a/make_release/release-note/create-pr.nu +++ b/make_release/notes/create-pr.nu @@ -1,7 +1,8 @@ -#!/usr/bin/env nu - use std log +use completions.nu * +use tools.nu release-notes + def open-pr [ repo: path remote: string @@ -31,13 +32,10 @@ def clean [repo: path] { } # open the release note PR interactively -# -# # Example -# [this PR](https://github.com/nushell/nushell.github.io/pull/916) has been created with the script -# > ./make_release/release-note/create-pr 0.81 2023-06-06 -def main [ - version: string # the version of the release, e.g. `0.80` - date: datetime # the date of the upcoming release, e.g. `2023-05-16` +@example "Create a PR for the next release" $"create-pr ($example_version) \(($current_build_date) + 6wk\)" +export def main [ + version: string@"nu-complete version" # the version of the release + date: datetime@"nu-complete date next" # the date of the upcoming release ] { let repo = ($nu.temp-path | path join (random uuid)) let branch = $"release-notes-($version)" @@ -49,22 +47,19 @@ def main [ let title = $"Release notes for `($version)`" let body = $"Please add your new features and breaking changes to the release notes by opening PRs against the `release-notes-($version)` branch. - ## TODO -- [ ] 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\) +- [ ] PRs that need to land before the release, e.g. [deprecations] or [removals] - [ ] add the full changelog - [ ] categorize each PR -- [ ] write all the sections and complete all the `TODO`s" +- [ ] write all the sections and complete all the `TODO`s +[deprecations]: https://github.com/nushell/nushell/labels/deprecation +[removals]: https://github.com/nushell/nushell/pulls?q=is%3Apr+is%3Aopen+label%3Aremoval-after-deprecation" - log info "creating release note from template" - let release_note = $env.CURRENT_FILE - | path dirname - | path join "template.md" - | open - | str replace --all "{{VERSION}}" $version + log info "generating release notes" + let release_note = release-notes $version log info $"branch: ($branch)" - log info $"blog: ($blog_path | str replace $repo "" | path split | skip 1 | path join)" + log info $"blog: ($blog_path | path relative-to $repo | path basename)" log info $"title: ($title)" match (["yes" "no"] | input list --fuzzy "Inspect the release note document? ") { @@ -76,48 +71,54 @@ by opening PRs against the `release-notes-($version)` branch. } let temp_file = $nu.temp-path | path join $"(random uuid).md" - $release_note | save --force $temp_file + [ + "" + "" + $release_note + ] | to text | save --force $temp_file ^$env.EDITOR $temp_file rm --recursive --force $temp_file }, - "no" | "" | _ => {}, + "no" | "" | _ => { } } match (["no" "yes"] | input list --fuzzy "Open release note PR? ") { - "yes" => {}, + "yes" => { }, "no" | "" | _ => { log warning "aborting." return - }, + } } log info "setting up nushell.github.io repo" - git clone https://github.com/nushell/nushell.github.io $repo --origin nushell --branch main --single-branch - git -C $repo remote set-url nushell --push git@github.com:nushell/nushell.github.io.git + ^git clone https://github.com/nushell/nushell.github.io $repo --origin nushell --branch main --single-branch + ^git -C $repo remote set-url nushell --push git@github.com:nushell/nushell.github.io.git log info "creating release branch" - git -C $repo checkout -b $branch + ^git -C $repo checkout -b $branch log info "writing release note" $release_note | save --force $blog_path log info "committing release note" - git -C $repo add $blog_path - git -C $repo commit -m $"($title)\n\n($body)" + ^git -C $repo add $blog_path + ^git -C $repo commit -m $"($title)\n\n($body)" log info "pushing release note to nushell" - git -C $repo push nushell $branch + ^git -C $repo push nushell $branch - let out = (do -i { gh auth status } | complete) + let out = (do -i { ^gh auth status } | complete) if $out.exit_code != 0 { clean $repo let pr_url = $"https://github.com/nushell/nushell.github.io/compare/($branch)?expand=1" error make --unspanned { - msg: ([ - $out.stderr - $"please open the PR manually from a browser (ansi blue_underline)($pr_url)(ansi reset)" - ] | str join "\n") + msg: ( + [ + $out.stderr + $"please open the PR manually from a browser (ansi blue_underline)($pr_url)(ansi reset)" + ] | str join "\n" + ) } } diff --git a/make_release/notes/generate.nu b/make_release/notes/generate.nu new file mode 100644 index 000000000..09daa2759 --- /dev/null +++ b/make_release/notes/generate.nu @@ -0,0 +1,178 @@ +# The sections to be included in the release notes +const SECTIONS = [ + [label, h2, h3]; + ["notes:breaking-changes", "Breaking changes", "Other breaking changes"] + ["notes:additions", "Additions", "Other additions"] + ["notes:deprecations", "Deprecations", "Other deprecations"] + ["notes:removals", "Removals", "Other removals"] + ["notes:other", "Other changes", "Additional changes"] + ["notes:fixes", "Bug fixes", "Other fixes"] + ["notes:mention", null, null] +] + +use notice.nu * +use util.nu * + +# Attempt to extract the "Release notes summary" section from a PR. +# +# Multiple checks are done to ensure that each PR has a valid release notes summary. +# If any issues are detected, a "notices" column with additional information is added. +export def get-release-notes []: record -> record { + mut pr = $in + + let has_ready_label = "notes:ready" in $pr.labels.name + let sections = $SECTIONS | where label in $pr.labels.name + let hall_of_fame = $SECTIONS | where label == "notes:mention" | only + + # Extract the notes section + mut notes = if "## Release notes summary" in $pr.body { + $pr.body | extract-notes + } else if $has_ready_label { + # If no release notes summary exists but ready label is set, treat as empty + $pr = $pr | add-notice warning "no release notes section but notes:ready label" + "" + } else { + return ($pr | add-notice error "no release notes section") + } + + # Check for empty notes section + if ($notes | is-empty-keyword) { + if ($sections | where label != "notes:mention" | is-not-empty) { + return ($pr | add-notice error "empty summary has a category other than Hall of Fame") + } + + if ($notes | is-empty) and not $has_ready_label { + $pr = $pr | add-notice warning "empty release notes section and no explicit label" + } + + $pr = $pr | insert section $hall_of_fame + $pr = $pr | insert notes ($pr.title | clean-title) + return $pr + } + + # If the notes section isn't empty, make sure we have the ready label + if not $has_ready_label { + return ($pr | add-notice error $"no notes:ready label") + } + + # Check that exactly one category is selected + let section = if ($sections | is-empty) { + $pr = $pr | add-notice info "no explicit release notes category selected (defaults to Hall of Fame)" + $hall_of_fame + } else if ($sections | length) > 1 { + return ($pr | add-notice error "multiple release notes categories selected") + } else { + $sections | only + } + + # Add section to PR + $pr = $pr | insert section $section + + let lines = $notes | lines | length + if $section.label == "notes:mention" and ($lines > 1) { + return ($pr | add-notice error "multi-line summaries in Hall of Fame section") + } + + # Add PR title as default heading for multi-line summaries + if $lines > 1 and not ($notes starts-with "###") { + $pr = $pr | add-notice info "multi-line summaries with no explicit title (using PR title as heading title)" + $notes = "### " + ($pr.title | clean-title) ++ (char nl) ++ $notes + } + + # Check for suspiciously short release notes section + if ($notes | split words | length) < 10 { + $pr = $pr | add-notice warning "release notes section that is less than 10 words" + } + + $pr | insert notes $notes +} + +# Extracts the "Release notes summary" section of the PR description +export def extract-notes []: string -> string { + lines + # skip until release notes heading + | skip until { $in starts-with "## Release notes summary" } + # this should already have been checked + | if ($in | is-empty) { assert false } else {} + | skip 1 # remove header + # extract until next heading + | take until { + $in starts-with "# " or $in starts-with "## " or $in starts-with "---" + } + | str join (char nl) + # remove HTML comments + | str replace -amr '' '' + | str trim +} + +# Generate the release notes from the list of PRs. +export def generate-notes [version: string]: table -> string { + let prs = $in + + const template_path = path self "template.md" + let template = open $template_path + let arguments = { + # chop off the `v` in the version + version: ($version | str substring 1..), + changes: ($prs | generate-changes-section), + hall_of_fame: ($prs | generate-hall-of-fame) + changelog: (generate-full-changelog $version) + } + + $arguments | format pattern $template +} + +# Generate the "Changes" section of the release notes. +export def generate-changes-section []: table -> string { + group-by --to-table section.label + | rename section prs + # sort sections in order of appearance in table + | sort-by {|i| $SECTIONS | enumerate | where item.label == $i.section | only } + # Hall of Fame is handled separately + | where section != "notes:mention" + | each { generate-section } + | str join (char nl) +} + +# Generate a subsection of the "Changes" section of the release notes. +export def generate-section []: record -> string { + let prs = $in.prs + let section = $prs.0.section + + mut body = [] + let multiline = $prs | where ($it.notes | lines | length) > 1 + let bullet = $prs | where ($it.notes | lines | length) == 1 + + # Add header + $body ++= [$"## ($section.h2)"] + + # Add multi-line summaries + $body ++= $multiline.notes + + # Add single-line summaries + if ($multiline | is-not-empty) { + $body ++= [$"### ($section.h3)"] + } + $body ++= $bullet | each {|pr| "* " ++ $pr.notes ++ $" \(($pr | pr-link)\)" } + + $body | str join (char nl) +} + +# Generate the "Hall of Fame" section of the release notes. +export def generate-hall-of-fame []: table -> string { + where section.label == "notes:mention" + # If the PR has no notes, use the title + | update notes {|pr| default -e $pr.title } + | update author { md-link $'@($in.login)' $'https://github.com/($in.login)' } + | insert link { pr-link } + | select author notes link + | rename -c {notes: change} + | to md + | escape-tag +} + +# Generate the "Full changelog" section of the release notes. +export def generate-full-changelog [version: string]: nothing -> string { + list-prs --milestone=$version + | pr-table +} diff --git a/make_release/release-note/gh-release-excerpt.nu b/make_release/notes/gh-release-excerpt.nu old mode 100755 new mode 100644 similarity index 97% rename from make_release/release-note/gh-release-excerpt.nu rename to make_release/notes/gh-release-excerpt.nu index 01713b2fc..4aaaeb58c --- a/make_release/release-note/gh-release-excerpt.nu +++ b/make_release/notes/gh-release-excerpt.nu @@ -1,8 +1,5 @@ -#!/usr/bin/env nu - - # Prepare the GitHub release text -def main [ +export def main [ versionname: string # The version we release now bloglink: string # The link to the blogpost date?: datetime # the date of the last release (default to 6 weeks ago, excluded) diff --git a/make_release/notes/mod.nu b/make_release/notes/mod.nu new file mode 100644 index 000000000..8ddf38ac4 --- /dev/null +++ b/make_release/notes/mod.nu @@ -0,0 +1,3 @@ +export use tools.nu * +export use gh-release-excerpt.nu +export use create-pr.nu diff --git a/make_release/notes/notice.nu b/make_release/notes/notice.nu new file mode 100644 index 000000000..ba7bacb1d --- /dev/null +++ b/make_release/notes/notice.nu @@ -0,0 +1,35 @@ +const TYPES = [ + [type, color, rank]; + [info, (ansi default), 0] + [warning, (ansi yellow), 1] + [error, (ansi red), 2] +] + +# Add an entry to the "notices" field of a PR +export def add-notice [type: string, message: string]: record -> record { + upsert notices { + append {type: $type, message: $message} + } +} + +export def group-notices []: table -> table { + let prs = $in + + $prs + | flatten -a notices + | group-by --to-table type? message? + | sort-by {|i| $TYPES | where type == $i.type | only rank } message +} + +# Print all of the notices associated with a PR +export def display-notices []: table -> nothing { + group-notices + | each {|e| + let color = $TYPES | where type == $e.type | only color + let number = $e.items | length + print $"($color)($number) PR\(s\) with ($e.message):" + $e.items | each { format-pr | print $"- ($in)" } + print "" + } + print -n (ansi reset) +} diff --git a/make_release/notes/template.md b/make_release/notes/template.md new file mode 100644 index 000000000..db58d618f --- /dev/null +++ b/make_release/notes/template.md @@ -0,0 +1,59 @@ +--- +title: Nushell {version} +author: The Nu Authors +author_site: https://www.nushell.sh/blog +author_image: https://www.nushell.sh/blog/images/nu_logo.png +excerpt: Today, we're releasing version {version} of Nu. This release adds... +--- + + + + + +# Nushell {version} + + + +Today, we're releasing version {version} of Nu. This release adds... + +# Where to get it + +Nu {version} is available as [pre-built binaries](https://github.com/nushell/nushell/releases/tag/{version}) or from [crates.io](https://crates.io/crates/nu). If you have Rust installed you can install it using `cargo install nu`. + +As part of this release, we also publish a set of optional [plugins](https://www.nushell.sh/book/plugins.html) you can install and use with Nushell. + +# Table of contents + + + +# Highlights and themes of this release + + + + +# Changes + +{changes} + +# Notes for plugin developers + +# Hall of fame + +Thanks to all the contributors below for helping us solve issues, improve documentation, refactor code, and more! :pray: + +{hall_of_fame} + +# Full changelog + +{changelog} diff --git a/make_release/release-note/notes.nu b/make_release/notes/tools.nu old mode 100755 new mode 100644 similarity index 62% rename from make_release/release-note/notes.nu rename to make_release/notes/tools.nu index 43fe6d0e6..65848aa8e --- a/make_release/release-note/notes.nu +++ b/make_release/notes/tools.nu @@ -1,14 +1,33 @@ -def md-link [text: string, link: string] { - $"[($text)]\(($link)\)" -} +# Tools for creating the release notes. +use std/assert +use std-rfc/iter only + +use util.nu * +use completions.nu * +use notice.nu * +use generate.nu * # List all merged PRs since the last release +@example $"List all merged for ($example_version)" $"list-prs --milestone ($example_version)" export def list-prs [ repo: string = 'nushell/nushell' # the name of the repo, e.g. 'nushell/nushell' - --since: datetime # list PRs on or after this date (defaults to 4 weeks ago if `--milestone` is not provided) - --milestone: string # only list PRs in a certain milestone + --since: datetime@"nu-complete date current" # list PRs on or after this date (defaults to 4 weeks ago if `--milestone` is not provided) + --milestone: string@"nu-complete version" # only list PRs in a certain milestone --label: string # the PR label to filter by, e.g. 'good-first-issue' -] { +]: nothing -> table { + query-prs $repo --since=$since --milestone=$milestone --label=$label + | select author title number mergedAt url + | sort-by mergedAt --reverse + | update author { get login } +} + +# Construct a GitHub query for merged PRs on a repo. +def query-prs [ + repo: string = 'nushell/nushell' # the name of the repo, e.g. 'nushell/nushell' + --since: datetime@"nu-complete date current" # list PRs on or after this date (defaults to 4 weeks ago if `--milestone` is not provided) + --milestone: string@"nu-complete version" # only list PRs in a certain milestone + --label: string # the PR label to filter by, e.g. 'good-first-issue' +]: nothing -> table { mut query_parts = [] if $since != null or $milestone == null { @@ -28,20 +47,47 @@ export def list-prs [ (gh --repo $repo pr list --state merged --limit (inf | into int) - --json author,title,number,mergedAt,url + --json author,title,number,mergedAt,url,body,labels --search $query) | from json - | sort-by mergedAt --reverse - | update author { get login } +} + +# Generate the release notes for the specified version. +export def release-notes [ + version: string@"nu-complete version" # the version to generate release notes for +]: nothing -> string { + query-prs --milestone=$version + | where not author.is_bot + | sort-by mergedAt + | each { get-release-notes } + | tee { display-notices } + | where {|pr| "error" not-in ($pr.notices?.type? | default []) } + | generate-notes $version +} + +# Check the release note summaries for the specified version. +export def check-prs [ + version: string@"nu-complete version" # the version to generate release notes for + --as-table (-t) # output PR checks as a table +]: [ + nothing -> nothing, + nothing -> table +] { + query-prs --milestone=$version + | where not author.is_bot + | sort-by mergedAt + | each { get-release-notes } + | if $as_table { group-notices } else { display-notices } } # Format the output of `list-prs` as a markdown table export def pr-table [] { sort-by author number | update author { md-link $'@($in)' $'https://github.com/($in)' } - | insert link {|pr| md-link $'#($pr.number)' $pr.url } + | insert link { pr-link } | select author title link | to md + | escape-tag } const toc = '[[toc](#table-of-contents)]' diff --git a/make_release/notes/util.nu b/make_release/notes/util.nu new file mode 100644 index 000000000..4116aefbc --- /dev/null +++ b/make_release/notes/util.nu @@ -0,0 +1,35 @@ +# Clean up a PR title +export def clean-title []: string -> string { + # remove any prefixes and capitalize + str replace -r '^[^\s]+: ' "" + | str trim + | str capitalize +} + +# Check if the release notes section was left empty +export def is-empty-keyword []: string -> bool { + str downcase | $in in ["", "n/a", "nothing", "none", "nan"] +} + +# Format a PR nicely, including a link +export def format-pr []: record -> string { + let pr = $in + let text = $"#($pr.number): ($pr.title)" + $pr.url | ansi link -t $text +} + +# Escape > and < +export def escape-tag [] { + str replace -a ">" ">" + | str replace -a "<" "<" +} + +# Create a markdown link +export def md-link [text: string, link: string] { + $"[($text)]\(($link)\)" +} + +# Get a link to a PR +export def pr-link []: record -> string { + md-link $"#($in.number)" $in.url +} diff --git a/make_release/release-note/template.md b/make_release/release-note/template.md deleted file mode 100644 index a56b762aa..000000000 --- a/make_release/release-note/template.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Nushell {{VERSION}} -author: The Nu Authors -author_site: https://www.nushell.sh/blog -author_image: https://www.nushell.sh/blog/images/nu_logo.png -excerpt: Today, we're releasing version {{VERSION}} of Nu. This release adds... ---- - - - - - -# Nushell {{VERSION}} - - - -Today, we're releasing version {{VERSION}} of Nu. This release adds... - -# Where to get it - -Nu {{VERSION}} is available as [pre-built binaries](https://github.com/nushell/nushell/releases/tag/{{VERSION}}) or from [crates.io](https://crates.io/crates/nu). If you have Rust installed you can install it using `cargo install nu`. - -As part of this release, we also publish a set of optional [plugins](https://www.nushell.sh/book/plugins.html) you can install and use with Nushell. - -# Table of contents - - - -# Highlights and themes of this release - - - - -# Changes - -## Additions - -## Breaking changes - -## Deprecations - -## Removals - -## Bug fixes and other changes - -# Notes for plugin developers - -# Hall of fame - -Thanks to all the contributors below for helping us solve issues, improve documentation, refactor code, and more! :pray: - -| author | title | link | -| ------------------------------------ | ----- | ------------------------------------------------------- | -| [@author](https://github.com/author) | ... | [#12345](https://github.com/nushell/nushell/pull/12345) | - -# Full changelog - -