mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-21 02:21:43 +00:00
.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
This commit is contained in:
parent
3d5c0af4e7
commit
3a97f1ac4f
2 changed files with 146 additions and 0 deletions
83
.github/scripts/commit-lint.nu
vendored
Normal file
83
.github/scripts/commit-lint.nu
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
63
.github/scripts/commit-lint.test.nu
vendored
Normal file
63
.github/scripts/commit-lint.test.nu
vendored
Normal file
|
|
@ -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 <u@e>", "feat: x", "feat: x\n\nFixes #1", true],
|
||||
["updates #N", "user <u@e>", "feat: x", "feat: x\n\nUpdates #1", true],
|
||||
["closes #N", "user <u@e>", "feat: x", "feat: x\n\nCloses #1", true],
|
||||
["resolves #N", "user <u@e>", "feat: x", "feat: x\n\nResolves #1", true],
|
||||
["for #N", "user <u@e>", "feat: x", "feat: x\n\nFor #1", true],
|
||||
["lowercase fixes", "user <u@e>", "feat: x", "feat: x\n\nfixes #1", true],
|
||||
["closed past", "user <u@e>", "feat: x", "feat: x\n\nclosed #1", true],
|
||||
["fixed past", "user <u@e>", "feat: x", "feat: x\n\nfixed #1", true],
|
||||
["resolved past", "user <u@e>", "feat: x", "feat: x\n\nresolved #1", true],
|
||||
["cross-repo", "user <u@e>", "feat: x", "feat: x\n\nUpdates juanfont/headscale#1", true],
|
||||
["url form", "user <u@e>", "feat: x", "feat: x\n\nFixes https://github.com/o/r/issues/1", true],
|
||||
["leading whitespace","user <u@e>", "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 <bot@example>", "feat: x", "feat: x", false],
|
||||
["revert subject", "user <u@e>", "Revert \"feat: x\"", "Revert \"feat: x\"", true],
|
||||
["#cleanup token", "user <u@e>", "fmt", "fmt\n\n#cleanup", true],
|
||||
["#cleanup w space", "user <u@e>", "fmt", "fmt\n\n #cleanup ", true],
|
||||
["skip-issuebot", "user <u@e>", "wip", "wip\n\nskip-issuebot", true],
|
||||
|
||||
# Rejected.
|
||||
["no ref", "user <u@e>", "feat: x", "feat: x", false],
|
||||
["verb without #", "user <u@e>", "feat: x", "feat: x\n\nFixes the parser", false],
|
||||
["# without verb", "user <u@e>", "feat: x", "feat: x\n\nrelated to bug #1", false],
|
||||
["fake bot in body", "user <u@e>", "feat: x", "feat: x\n\nlooks like a [bot]", false],
|
||||
["fake revert body", "user <u@e>", "feat: x", "feat: x\n\nRevert this later", false],
|
||||
["#cleanup not alone","user <u@e>", "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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue