Files
cozystack/hack/pr-labeler-contract.bats
Myasnikov Daniil cc942fed68 test(tests): run unit suites with Bats and retain POSIX coverage
The shell translator rewrites test bodies and loses Bats reporting.
The unit lane needs complete TAP results and JUnit reports without
changing how live-cluster suites run.

Sourced production helpers still need a dash pass, and unit test bodies
need nounset without exporting shell options into subprocesses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-10-07 11:04:00 +03:00

202 lines
7.2 KiB
Bash

#!/usr/bin/env bats
# Contract for the PR title labeler in .github/workflows/pr-labeler.yaml.
#
# Every failure this guards is silent: the job either dies on a title it cannot
# handle, applies a label that does not exist, or lands a PR in the wrong area
# with no warning, and nobody reads the log of a labeler that did not label.
#
# The behavioural tests run the workflow's own github-script block under node,
# extracted from the file rather than mirrored, with `context`, `core` and
# `github` stubbed, so an edit to the script is what gets tested. node is
# required, not optional: a suite that skips when the runtime is missing goes
# green having run nothing.
load test_helper
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
WF="$REPO_ROOT/.github/workflows/pr-labeler.yaml"
LABELS="$REPO_ROOT/.github/labels.yml"
need_node() {
command -v node >/dev/null 2>&1 && return 0
echo "node is required to run the pr-labeler script" >&2
return 1
}
# The `script: |` block, de-indented to plain JavaScript.
extract_script() {
awk 'f { print substr($0, 13) } /^ +script: \|$/ { f = 1 }' "$WF" > "$1"
[ -s "$1" ]
}
# Runs the extracted script once per title read from stdin, all in one node
# process, and prints one line per title: the title, a tab, then the applied
# labels sorted and comma-joined, or THROW: and the error message.
#
# JavaScript closing braces below are indented on purpose: hack/cozytest.sh
# rewrites any line that is exactly `}` into a shell `return 0`.
run_labeler() {
tmp="$(mktemp -d)"
extract_script "$tmp/script.js"
cat > "$tmp/run.js" <<'JS'
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
const AsyncFunction = (async () => {}).constructor;
const fn = new AsyncFunction('context', 'core', 'github', src);
(async () => {
const out = [];
const titles = fs.readFileSync(0, 'utf8').split('\n').filter(Boolean);
for (const title of titles) {
let applied = [];
const context = {
repo: { owner: 'o', repo: 'r' },
payload: { pull_request: { number: 1, title, body: '', labels: [] } },
};
const core = { warning() {}, info() {} };
const github = { rest: { issues: { addLabels: async (a) => { applied = a.labels; } } } };
try {
await fn(context, core, github);
out.push(title + '\t' + [...applied].sort().join(','));
} catch (e) {
out.push(title + '\tTHROW: ' + e.message);
}
}
fs.writeFileSync(1, out.join('\n') + '\n');
})();
JS
node "$tmp/run.js" "$tmp/script.js"
rm -rf "$tmp"
}
# Asserts the labeler's output for each `title<TAB>labels` line on stdin.
expect_labels() {
want="$(cat)"
got="$(printf '%s\n' "$want" | cut -f1 | run_labeler)"
[ "$got" = "$want" ] || {
echo "labeler output mismatch." >&2
echo "want:" >&2; printf '%s\n' "$want" | sed 's/^/ /' >&2
echo "got:" >&2; printf '%s\n' "$got" | sed 's/^/ /' >&2
return 1
}
}
# The scopeToArea literal as JSON pairs, plus a count of the key lines in the
# source, quoted either way or bare. A duplicate key collapses when the literal
# is evaluated, so the two disagree when a key is written twice, as long as each
# key sits on its own line.
scope_map() { # json|keylines
tmp="$(mktemp -d)"
extract_script "$tmp/script.js"
cat > "$tmp/map.js" <<'JS'
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
const m = src.match(/const scopeToArea = (\{[\s\S]*?\n\};)/);
if (!m) { throw new Error('scopeToArea literal not found'); }
const lines = m[1].split('\n').filter((l) => /^\s*(['"]?)[^'"\s:]+\1\s*:/.test(l));
const map = new Function('return ' + m[1].replace(/;$/, ''))();
if (process.argv[3] === 'keylines') {
fs.writeFileSync(1, lines.length + ' ' + Object.keys(map).length + '\n');
} else {
fs.writeFileSync(1, Object.entries(map).map(([k, v]) => k + '\t' + v).join('\n') + '\n');
}
JS
node "$tmp/map.js" "$tmp/script.js" "$1"
rm -rf "$tmp"
}
area_labels() {
sed -n 's/^- name: \(area\/[^[:space:]]*\)[[:space:]]*$/\1/p' "$LABELS"
}
@test "the workflow under contract exists" {
[ -f "$WF" ]
[ -f "$LABELS" ]
}
# A key inherited from Object.prototype resolves to a function, which is truthy,
# lands in the label set, and then kills the job on `l.startsWith`.
@test "a scope or type named after an Object.prototype member does not kill the job" {
need_node
expect_labels <<'EOF'
fix(constructor): x area/uncategorized,kind/bug
fix(toString): x area/uncategorized,kind/bug
fix(hasOwnProperty): x area/uncategorized,kind/bug
fix(__proto__): x area/uncategorized,kind/bug
[constructor] x area/uncategorized
constructor: x area/uncategorized
EOF
}
# One stripped prefix left the second one to be parsed as a bracket scope, which
# dropped the real scope and the kind, and the area/release from the first strip
# suppressed the uncategorized signal.
@test "every leading backport prefix is stripped before the title is parsed" {
need_node
expect_labels <<'EOF'
[Backport release-1.3] fix(linstor): x area/release,area/storage,kind/bug
[Backport release-1.2] [Backport release-1.3] fix(linstor): x area/release,area/storage,kind/bug
[Backport release-1.2] [Backport release-1.3] [Backport release-1.4] feat(linstor): x area/release,area/storage,kind/feature
EOF
}
@test "an unmapped scope still falls back to area/uncategorized" {
need_node
expect_labels <<'EOF'
fix(no-such-scope): x area/uncategorized,kind/bug
fix: x area/uncategorized,kind/bug
just a sentence area/uncategorized
EOF
}
@test "recurring scopes resolve to their area" {
need_node
expect_labels <<'EOF'
fix(etcd-operator): x area/database,kind/bug
fix(opensearch): x area/database,kind/bug
fix(multus): x area/networking,kind/bug
fix(kubeovn): x area/networking,kind/bug
fix(kubevirt-operator): x area/virtualization,kind/bug
fix(keycloak-configure): x area/keycloak,kind/bug
docs(changelog): x area/release,kind/documentation
fix(lineage): x area/platform,kind/bug
fix(piraeus-operator): x area/storage,kind/bug
fix(monitoring-agents): x area/monitoring,kind/bug
ci(workflows): x area/ci
fix(hack): x area/dx,kind/bug
EOF
}
# Contributors reach for the subsystem name as often as for a package name, so
# every area is reachable by its own name.
@test "every area label is accepted as a scope that maps to itself" {
need_node
map="$(scope_map json)"
missing=""
for a in $(area_labels); do
[ "$a" = "area/uncategorized" ] && continue
printf '%s\n' "$map" | grep -qxF "${a#area/} $a" || missing="$missing ${a#area/}"
done
[ -z "$missing" ] || { echo "scopes missing for their own area:$missing" >&2; return 1; }
}
# A target missing from labels.yml is a label the sync workflow never creates,
# and nothing in a run of the labeler reports it.
@test "every scopeToArea target is an area label defined in labels.yml" {
need_node
defined="$(area_labels)"
[ -n "$defined" ]
bad="$(scope_map json | cut -f2 | sort -u | grep -vxF "$defined" || true)"
[ -z "$bad" ] || { echo "targets not in labels.yml: $bad" >&2; return 1; }
}
# A repeated key silently overrides the earlier value and reads as an ordinary
# added line in review.
@test "no scope key appears twice in scopeToArea" {
need_node
read -r lines keys <<EOF
$(scope_map keylines)
EOF
[ "$lines" -gt 0 ]
[ "$lines" = "$keys" ] || { echo "$lines key lines but $keys distinct keys" >&2; return 1; }
}