|
| 1 | +# SPDX-FileCopyrightText: © 2022 Michael Goerz <mail@michaelgoerz.net> |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +# Validate (and optionally fix) the reference-style links in CHANGELOG.md. |
| 6 | +# |
| 7 | +# The default check mode is purely textual: it makes no network calls and |
| 8 | +# inspects no git state. It verifies that every reference used in the prose |
| 9 | +# (e.g. `[#97]`, `[v0.8.0]`) has a matching link definition, that no definition |
| 10 | +# is unused or duplicated, that each version link points to its own release tag, |
| 11 | +# and that the `[Unreleased]` link compares against the latest version heading |
| 12 | +# in the file. |
| 13 | +# |
| 14 | +# The `--fix` mode (only) may make network calls: any missing `[#N]` |
| 15 | +# issue/pull-request link target is added, consulting the GitHub API to decide |
| 16 | +# whether `#N` is an issue or a pull request and to confirm it exists: |
| 17 | +# |
| 18 | +# * If the API confirms `#N` exists, the correct `issues/` or `pull/` URL is |
| 19 | +# added. |
| 20 | +# * If the API definitively reports that `#N` does not exist (HTTP 404), it is |
| 21 | +# treated as a typo: no link is added and the reference is reported. |
| 22 | +# * If the API cannot be reached (offline, rate-limited), verification is |
| 23 | +# skipped and an `issues/` URL is added (GitHub redirects `issues/N` ↔ |
| 24 | +# `pull/N`, so it still resolves). |
| 25 | +# |
| 26 | +# Reading issues/PRs of a public repository uses the `gh` CLI |
| 27 | +# when available, and otherwise the unauthenticated public API via `curl`. |
| 28 | +# |
| 29 | +# Run with `julia test/check_changelog.jl [--fix] [path]` (no project |
| 30 | +# dependencies), or via `make check-changelog` / `make changelog`. Exits |
| 31 | +# non-zero on any remaining problem. |
| 32 | + |
| 33 | +const FIX = "--fix" in ARGS |
| 34 | +const positional = filter(a -> a != "--fix", ARGS) |
| 35 | +const CHANGELOG = |
| 36 | + isempty(positional) ? joinpath(@__DIR__, "..", "CHANGELOG.md") : positional[1] |
| 37 | +const FALLBACK_BASE = "https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl" |
| 38 | + |
| 39 | +const DEFINITION = r"^\[([^\]]+)\]:[ \t]*(\S+)[ \t]*$" |
| 40 | +const INLINE_LABEL = r"\[([^\[\]]+)\]\(" # `[text](url)` — not a reference |
| 41 | +const REFERENCE = r"\[([^\[\]]+)\]" # innermost `[label]` |
| 42 | +const VERSION_HEADING = r"^##[ \t]+\[(v[0-9]+\.[0-9]+\.[0-9]+)\]" |
| 43 | +const ISSUE_LABEL = r"^#([0-9]+)$" |
| 44 | + |
| 45 | +# Parse the file into its link definitions (label => url), the order in which |
| 46 | +# labels first appear, duplicated labels, the references used in the prose, and |
| 47 | +# the latest version (topmost `## [vX.Y.Z]` heading). |
| 48 | +function parse_changelog(lines) |
| 49 | + defs = Dict{String,String}() |
| 50 | + order = String[] |
| 51 | + dups = String[] |
| 52 | + body = IOBuffer() |
| 53 | + for line in lines |
| 54 | + m = match(DEFINITION, line) |
| 55 | + if m === nothing |
| 56 | + println(body, line) |
| 57 | + else |
| 58 | + label = m.captures[1] |
| 59 | + haskey(defs, label) ? push!(dups, label) : push!(order, label) |
| 60 | + defs[label] = m.captures[2] |
| 61 | + end |
| 62 | + end |
| 63 | + prose = String(take!(body)) |
| 64 | + inline = Set(m.captures[1] for m in eachmatch(INLINE_LABEL, prose)) |
| 65 | + used = Set( |
| 66 | + m.captures[1] for m in eachmatch(REFERENCE, prose) if !(m.captures[1] in inline) |
| 67 | + ) |
| 68 | + latest = nothing |
| 69 | + for line in lines |
| 70 | + m = match(VERSION_HEADING, line) |
| 71 | + if m !== nothing |
| 72 | + latest = m.captures[1] |
| 73 | + break |
| 74 | + end |
| 75 | + end |
| 76 | + return (; defs, order, dups, used, latest) |
| 77 | +end |
| 78 | + |
| 79 | +# The `https://github.com/owner/repo` base, derived from existing definitions so |
| 80 | +# the script is not tied to a hardcoded repository, with a fallback constant. |
| 81 | +function github_base(defs) |
| 82 | + for url in values(defs) |
| 83 | + m = match(r"^(https://github\.com/[^/]+/[^/]+)", url) |
| 84 | + m === nothing || return m.captures[1] |
| 85 | + end |
| 86 | + return FALLBACK_BASE |
| 87 | +end |
| 88 | + |
| 89 | +# Run `cmd`, capturing (exitcode, stdout, stderr). exitcode is `nothing` if the |
| 90 | +# command could not be launched at all (e.g. the tool is not installed). |
| 91 | +function run_capture(cmd) |
| 92 | + out = Pipe() |
| 93 | + err = Pipe() |
| 94 | + proc = try |
| 95 | + run(pipeline(ignorestatus(cmd); stdout = out, stderr = err); wait = false) |
| 96 | + catch |
| 97 | + return (nothing, "", "") |
| 98 | + end |
| 99 | + close(out.in) |
| 100 | + close(err.in) |
| 101 | + sout = @async read(out, String) |
| 102 | + serr = @async read(err, String) |
| 103 | + wait(proc) |
| 104 | + return (proc.exitcode, fetch(sout), fetch(serr)) |
| 105 | +end |
| 106 | + |
| 107 | +# Classify `#n` via the `gh` CLI. Returns :pull, :issue, :missing, :network, or |
| 108 | +# :unavailable (gh not installed or not authenticated → try another method). |
| 109 | +function classify_gh(slug, n) |
| 110 | + jq = "if has(\"pull_request\") then \"pull\" else \"issue\" end" |
| 111 | + code, out, err = run_capture(`gh api repos/$slug/issues/$n --jq $jq`) |
| 112 | + code === nothing && return :unavailable |
| 113 | + if code == 0 |
| 114 | + kind = strip(out) |
| 115 | + kind == "pull" && return :pull |
| 116 | + kind == "issue" && return :issue |
| 117 | + return :network |
| 118 | + end |
| 119 | + if occursin("HTTP 404", err) || occursin("Not Found", err) |
| 120 | + return :missing |
| 121 | + elseif occursin("HTTP 401", err) || occursin(r"auth"i, err) |
| 122 | + return :unavailable |
| 123 | + end |
| 124 | + return :network |
| 125 | +end |
| 126 | + |
| 127 | +# Classify `#n` via the unauthenticated public REST API using `curl`. |
| 128 | +function classify_curl(slug, n) |
| 129 | + url = "https://api.github.com/repos/$slug/issues/$n" |
| 130 | + code, out, _ = run_capture( |
| 131 | + `curl -sS -H "Accept: application/vnd.github+json" -w $("\n%{http_code}") $url` |
| 132 | + ) |
| 133 | + code === nothing && return :unavailable |
| 134 | + code == 0 || return :network |
| 135 | + nl = findlast('\n', out) |
| 136 | + status = nl === nothing ? "" : strip(out[nextind(out, nl):end]) |
| 137 | + payload = nl === nothing ? out : out[1:prevind(out, nl)] |
| 138 | + status == "200" && return occursin("\"pull_request\"", payload) ? :pull : :issue |
| 139 | + status == "404" && return :missing |
| 140 | + return :network |
| 141 | +end |
| 142 | + |
| 143 | +# Determine whether `#n` is an issue or pull request (or is missing / unknown). |
| 144 | +function classify_issue(slug, n) |
| 145 | + result = classify_gh(slug, n) |
| 146 | + result === :unavailable || return result |
| 147 | + result = classify_curl(slug, n) |
| 148 | + result === :unavailable ? :network : result |
| 149 | +end |
| 150 | + |
| 151 | +# Append missing `[#N]` link targets. Returns (modified::Bool, problems::Vector). |
| 152 | +function fix_missing!(lines) |
| 153 | + parsed = parse_changelog(lines) |
| 154 | + missing = sort!([ |
| 155 | + parse(Int, match(ISSUE_LABEL, ref).captures[1]) for |
| 156 | + ref in parsed.used if occursin(ISSUE_LABEL, ref) && !haskey(parsed.defs, ref) |
| 157 | + ]) |
| 158 | + isempty(missing) && return (false, String[]) |
| 159 | + |
| 160 | + base = github_base(parsed.defs) |
| 161 | + slug = replace(base, "https://github.com/" => "") |
| 162 | + nonissue = Tuple{String,String}[] |
| 163 | + issues = Dict{Int,String}() |
| 164 | + for label in parsed.order |
| 165 | + m = match(ISSUE_LABEL, label) |
| 166 | + if m === nothing |
| 167 | + push!(nonissue, (label, parsed.defs[label])) |
| 168 | + else |
| 169 | + issues[parse(Int, m.captures[1])] = parsed.defs[label] |
| 170 | + end |
| 171 | + end |
| 172 | + |
| 173 | + problems = String[] |
| 174 | + added = 0 |
| 175 | + for n in missing |
| 176 | + kind = classify_issue(slug, n) |
| 177 | + if kind === :missing |
| 178 | + push!( |
| 179 | + problems, |
| 180 | + "reference [#$n] does not exist on GitHub (typo?); no link added", |
| 181 | + ) |
| 182 | + continue |
| 183 | + end |
| 184 | + issues[n] = kind === :pull ? "$base/pull/$n" : "$base/issues/$n" |
| 185 | + note = kind === :network ? " (could not verify via GitHub; assuming issue)" : "" |
| 186 | + println("Added link target [#$n]: $(issues[n])$note") |
| 187 | + added += 1 |
| 188 | + end |
| 189 | + added == 0 && return (false, problems) |
| 190 | + |
| 191 | + # Rebuild: prose (trailing blanks trimmed), one blank separator, then all |
| 192 | + # definitions — non-issue defs in original order, then `#N` defs sorted. |
| 193 | + prose_lines = [line for line in lines if !occursin(DEFINITION, line)] |
| 194 | + while !isempty(prose_lines) && isempty(strip(prose_lines[end])) |
| 195 | + pop!(prose_lines) |
| 196 | + end |
| 197 | + out = copy(prose_lines) |
| 198 | + push!(out, "") |
| 199 | + for (label, url) in nonissue |
| 200 | + push!(out, "[$label]: $url") |
| 201 | + end |
| 202 | + for n in sort(collect(keys(issues))) |
| 203 | + push!(out, "[#$n]: $(issues[n])") |
| 204 | + end |
| 205 | + write(CHANGELOG, join(out, "\n") * "\n") |
| 206 | + return (true, problems) |
| 207 | +end |
| 208 | + |
| 209 | +function validate(lines) |
| 210 | + parsed = parse_changelog(lines) |
| 211 | + (; defs, dups, used, latest) = parsed |
| 212 | + errors = String[] |
| 213 | + |
| 214 | + for u in sort(collect(used)) |
| 215 | + haskey(defs, u) || |
| 216 | + push!(errors, "reference [$u] is used but has no link definition") |
| 217 | + end |
| 218 | + for d in sort(collect(keys(defs))) |
| 219 | + # `[Unreleased]` is structural and may be defined without a corresponding |
| 220 | + # heading on a `release-*` branch, where the heading is temporarily removed. |
| 221 | + d == "Unreleased" && continue |
| 222 | + d in used || push!(errors, "link definition [$d] is never used") |
| 223 | + end |
| 224 | + for d in sort(unique(dups)) |
| 225 | + push!(errors, "link definition [$d] is duplicated") |
| 226 | + end |
| 227 | + |
| 228 | + # Each version link should point to its own release tag. |
| 229 | + for (label, url) in defs |
| 230 | + startswith(label, "v") || continue |
| 231 | + endswith(url, "/releases/tag/$label") || |
| 232 | + push!(errors, "[$label] should point to `…/releases/tag/$label`, not `$url`") |
| 233 | + end |
| 234 | + |
| 235 | + # The `[Unreleased]` link should compare the latest version against HEAD. |
| 236 | + if !haskey(defs, "Unreleased") |
| 237 | + push!(errors, "missing the [Unreleased] link definition") |
| 238 | + elseif latest === nothing |
| 239 | + push!( |
| 240 | + errors, |
| 241 | + "no `## [vX.Y.Z]` version heading found to anchor the [Unreleased] link", |
| 242 | + ) |
| 243 | + elseif !endswith(defs["Unreleased"], "/compare/$latest..HEAD") |
| 244 | + push!( |
| 245 | + errors, |
| 246 | + "[Unreleased] should compare against the latest version `$latest` " * |
| 247 | + "(expected `…/compare/$latest..HEAD`), not `$(defs["Unreleased"])`", |
| 248 | + ) |
| 249 | + end |
| 250 | + |
| 251 | + return errors |
| 252 | +end |
| 253 | + |
| 254 | +function main() |
| 255 | + isfile(CHANGELOG) || (@error "Not found: $CHANGELOG"; exit(1)) |
| 256 | + lines = readlines(CHANGELOG) |
| 257 | + |
| 258 | + if FIX |
| 259 | + _, problems = fix_missing!(lines) |
| 260 | + for p in problems |
| 261 | + @warn p |
| 262 | + end |
| 263 | + lines = readlines(CHANGELOG) |
| 264 | + end |
| 265 | + |
| 266 | + errors = validate(lines) |
| 267 | + if isempty(errors) |
| 268 | + n = count(line -> occursin(DEFINITION, line), lines) |
| 269 | + println("CHANGELOG.md: OK ($n link definitions)") |
| 270 | + exit(0) |
| 271 | + else |
| 272 | + @error "CHANGELOG.md has $(length(errors)) problem(s):\n " * join(errors, "\n ") |
| 273 | + exit(1) |
| 274 | + end |
| 275 | +end |
| 276 | + |
| 277 | +main() |
0 commit comments