From 3a97f1ac4f104e569833895ab59bd4f6a1144fc2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 29 Apr 2026 13:49:49 +0000 Subject: [PATCH] .github/scripts: add commit-lint rule with tests Pure nushell rule modelled after tailscale/issuebot. Verbs (case- insensitive, line start): close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved, updates, for. Same line must contain '#' or 'github.com', so cross-repo refs (org/repo#N) and full GitHub URLs are accepted. Skip tokens: '[bot]' authors, Revert subjects, '#cleanup' lines, 'skip-issuebot' anywhere in the body. The rule is a pure function over (author, subject, body) so the 25-case fixture suite drives it directly without spawning git. The lint-range driver walks `git rev-list base..head` and emits GitHub workflow annotations on failure. skip-issuebot --- .github/scripts/commit-lint.nu | 83 +++++++++++++++++++++++++++++ .github/scripts/commit-lint.test.nu | 63 ++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 .github/scripts/commit-lint.nu create mode 100644 .github/scripts/commit-lint.test.nu diff --git a/.github/scripts/commit-lint.nu b/.github/scripts/commit-lint.nu new file mode 100644 index 000000000..ee76fd966 --- /dev/null +++ b/.github/scripts/commit-lint.nu @@ -0,0 +1,83 @@ +# Commit message linter for headscale. +# +# Mirrors the rule in tailscale/issuebot (cmd/issuebot/issuebot.go:107-145): +# every non-trivial commit in a pull request must reference an issue. +# +# A commit passes if any of the following hold: +# +# - The author name or email contains "[bot]" (dependabot, renovate, etc.). +# - The subject starts with "Revert " (revert inherits context). +# - A line in the body equals "#cleanup" (trivial cleanup escape hatch). +# - The token "skip-issuebot" appears anywhere in the body. +# - At least one line in subject or body matches: +# (?i)^\s*(close[ds]?|fix(es|ed)?|resolve[ds]?|updates|for)\b.*(#|github.com) +# +# Cross-repo refs (org/repo#N) and full GitHub issue URLs are accepted because +# the rule only requires the literal "#" or substring "github.com" on the +# matching line. + +const ISSUE_REF_PATTERN = '(?im)^\s*(close[ds]?|fix(es|ed)?|resolve[ds]?|updates|for)\b.*(#|github\.com)' + +# Pure rule check. Returns {ok: bool, reason: string}. +export def check-commit-message [ + author: string, + subject: string, + body: string, +] { + if ($author | str contains '[bot]') { + return {ok: true, reason: "skip:bot-author"} + } + + if ($subject | str starts-with 'Revert ') { + return {ok: true, reason: "skip:revert"} + } + + let lines = ($body | lines) + + if ($lines | any {|l| ($l | str trim) == '#cleanup' }) { + return {ok: true, reason: "skip:cleanup"} + } + + if ($lines | any {|l| $l | str contains 'skip-issuebot' }) { + return {ok: true, reason: "skip:skip-issuebot"} + } + + if ($body =~ $ISSUE_REF_PATTERN) { + return {ok: true, reason: "ref:matched"} + } + + {ok: false, reason: "no issue reference"} +} + +# Walk `git rev-list $base..$head` and lint each commit. Emits GitHub +# workflow annotations on failure and exits 1 if any commit fails. +export def lint-range [base: string, head: string] { + let raw = (git rev-list $"($base)..($head)" | str trim) + if ($raw | is-empty) { + print "No commits to lint." + return + } + let shas = ($raw | lines) + + mut failed = 0 + for sha in $shas { + let author = (git log -1 --format='%an <%ae>' $sha | str trim) + let subject = (git log -1 --format='%s' $sha | str trim) + let body = (git log -1 --format='%B' $sha) + let result = (check-commit-message $author $subject $body) + let short = ($sha | str substring 0..7) + if $result.ok { + print $"ok ($short) ($result.reason) ($subject)" + } else { + print $"::error::Commit ($sha) does not reference an issue. Add 'Fixes #N' or 'Updates #N', or use a skip token." + print $" subject: ($subject)" + print $" author: ($author)" + $failed = $failed + 1 + } + } + + if $failed > 0 { + print $"::error::($failed) commits missing issue reference." + exit 1 + } +} diff --git a/.github/scripts/commit-lint.test.nu b/.github/scripts/commit-lint.test.nu new file mode 100644 index 000000000..bb3e4d94e --- /dev/null +++ b/.github/scripts/commit-lint.test.nu @@ -0,0 +1,63 @@ +# Unit tests for commit-lint.nu. +# +# Drives the pure rule with fixture records (no git, no I/O). Run with: +# +# nu .github/scripts/commit-lint.test.nu +# +# Exits non-zero on the first failing case. + +use std/assert +use commit-lint.nu * + +def cases [] { + [ + # [label, author, subject, body, expect_ok] + + # Accepted refs. + ["fixes #N", "user ", "feat: x", "feat: x\n\nFixes #1", true], + ["updates #N", "user ", "feat: x", "feat: x\n\nUpdates #1", true], + ["closes #N", "user ", "feat: x", "feat: x\n\nCloses #1", true], + ["resolves #N", "user ", "feat: x", "feat: x\n\nResolves #1", true], + ["for #N", "user ", "feat: x", "feat: x\n\nFor #1", true], + ["lowercase fixes", "user ", "feat: x", "feat: x\n\nfixes #1", true], + ["closed past", "user ", "feat: x", "feat: x\n\nclosed #1", true], + ["fixed past", "user ", "feat: x", "feat: x\n\nfixed #1", true], + ["resolved past", "user ", "feat: x", "feat: x\n\nresolved #1", true], + ["cross-repo", "user ", "feat: x", "feat: x\n\nUpdates juanfont/headscale#1", true], + ["url form", "user ", "feat: x", "feat: x\n\nFixes https://github.com/o/r/issues/1", true], + ["leading whitespace","user ", "feat: x", "feat: x\n\n Fixes #1", true], + + # Skip tokens. + ["bot author", "dependabot[bot] <>", "bump deps", "bump deps", true], + ["renovate bot", "renovate[bot] <>", "deps", "deps", true], + ["bot author email", "ci ", "feat: x", "feat: x", false], + ["revert subject", "user ", "Revert \"feat: x\"", "Revert \"feat: x\"", true], + ["#cleanup token", "user ", "fmt", "fmt\n\n#cleanup", true], + ["#cleanup w space", "user ", "fmt", "fmt\n\n #cleanup ", true], + ["skip-issuebot", "user ", "wip", "wip\n\nskip-issuebot", true], + + # Rejected. + ["no ref", "user ", "feat: x", "feat: x", false], + ["verb without #", "user ", "feat: x", "feat: x\n\nFixes the parser", false], + ["# without verb", "user ", "feat: x", "feat: x\n\nrelated to bug #1", false], + ["fake bot in body", "user ", "feat: x", "feat: x\n\nlooks like a [bot]", false], + ["fake revert body", "user ", "feat: x", "feat: x\n\nRevert this later", false], + ["#cleanup not alone","user ", "feat: x", "feat: x\n\nstart #cleanup end", false], + ] +} + +def main [] { + let rows = (cases) + mut passed = 0 + for row in $rows { + let label = ($row | get 0) + let author = ($row | get 1) + let subject = ($row | get 2) + let body = ($row | get 3) + let expected = ($row | get 4) + let result = (check-commit-message $author $subject $body) + assert ($result.ok == $expected) $"case '($label)': expected ok=($expected), got ($result)" + $passed = $passed + 1 + } + print $"all ($passed) cases passed" +}