mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 12:31:36 +00:00
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
// Check Changelog Attributions tests cover check changelog attributions script behavior.
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
findForbiddenChangelogThanks,
|
|
isForbiddenChangelogThanksHandle,
|
|
requiresExplicitHumanChangelogThanks,
|
|
} from "../../scripts/check-changelog-attributions.mjs";
|
|
|
|
const changelogScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "changelog.sh");
|
|
const commonScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "common.sh");
|
|
const gatesScriptPath = path.join(process.cwd(), "scripts", "pr-lib", "gates.sh");
|
|
|
|
function run(cwd: string, command: string, args: string[], env?: NodeJS.ProcessEnv): string {
|
|
return execFileSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: env ? { ...process.env, ...env } : process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}).trim();
|
|
}
|
|
|
|
function commandOutput(error: unknown): string {
|
|
const result = error as { stderr?: unknown; stdout?: unknown };
|
|
return `${String(result.stdout ?? "")}${String(result.stderr ?? "")}`;
|
|
}
|
|
|
|
function createRepoWithPrChangelogDiff(entry: string): string {
|
|
const repo = mkdtempSync(path.join(os.tmpdir(), "openclaw-changelog-credit-"));
|
|
run(repo, "git", ["init", "-q", "--initial-branch=main"]);
|
|
run(repo, "git", ["config", "user.email", "test@example.com"]);
|
|
run(repo, "git", ["config", "user.name", "Test User"]);
|
|
writeFileSync(repo + "/CHANGELOG.md", "# Changelog\n\n## Unreleased\n\n### Fixes\n\n", "utf8");
|
|
run(repo, "git", ["add", "CHANGELOG.md"]);
|
|
run(repo, "git", ["commit", "-qm", "seed"]);
|
|
const baseSha = run(repo, "git", ["rev-parse", "HEAD"]);
|
|
// validate_changelog_entry_for_pr reads origin/main...HEAD, so the test
|
|
// fixture needs a real base ref plus a feature-branch changelog diff.
|
|
run(repo, "git", ["update-ref", "refs/remotes/origin/main", baseSha]);
|
|
run(repo, "git", ["checkout", "-qb", "feature"]);
|
|
writeFileSync(
|
|
repo + "/CHANGELOG.md",
|
|
`# Changelog\n\n## Unreleased\n\n### Fixes\n\n${entry}\n`,
|
|
"utf8",
|
|
);
|
|
run(repo, "git", ["add", "CHANGELOG.md"]);
|
|
run(repo, "git", ["commit", "-qm", "add changelog entry"]);
|
|
return repo;
|
|
}
|
|
|
|
function createRepoWithChangelog(content: string): string {
|
|
const repo = mkdtempSync(path.join(os.tmpdir(), "openclaw-changelog-policy-"));
|
|
writeFileSync(repo + "/CHANGELOG.md", content, "utf8");
|
|
return repo;
|
|
}
|
|
|
|
function validateChangelogEntry(repo: string, contrib: string): string {
|
|
return run(
|
|
repo,
|
|
"bash",
|
|
[
|
|
"-c",
|
|
'source "$OPENCLAW_PR_CHANGELOG_SH"; validate_changelog_entry_for_pr 123 "$OPENCLAW_TEST_CONTRIB"',
|
|
],
|
|
{
|
|
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
|
OPENCLAW_TEST_CONTRIB: contrib,
|
|
},
|
|
);
|
|
}
|
|
|
|
function validateChangelogAttributionPolicy(repo: string): string {
|
|
return run(
|
|
repo,
|
|
"bash",
|
|
["-c", 'source "$OPENCLAW_PR_CHANGELOG_SH"; validate_changelog_attribution_policy'],
|
|
{
|
|
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("check-changelog-attributions", () => {
|
|
it("flags forbidden bot, org, and maintainer thanks attributions", () => {
|
|
const content = [
|
|
"- Internal cleanup. Thanks @codex.",
|
|
"- Org-owned fix. Thanks @openclaw.",
|
|
"- Maintainer-owned fix. Thanks @steipete.",
|
|
"- Mixed credit. Thanks @contributor and @OpenClaw.",
|
|
"- Bot repair. Thanks @clawsweeper[bot].",
|
|
"- Dependency bump. Thanks @dependabot[bot].",
|
|
"- App repair. Thanks @app/clawsweeper.",
|
|
].join("\n");
|
|
|
|
expect(findForbiddenChangelogThanks(content)).toEqual([
|
|
{ line: 1, handle: "codex", text: "- Internal cleanup. Thanks @codex." },
|
|
{ line: 2, handle: "openclaw", text: "- Org-owned fix. Thanks @openclaw." },
|
|
{ line: 3, handle: "steipete", text: "- Maintainer-owned fix. Thanks @steipete." },
|
|
{ line: 4, handle: "openclaw", text: "- Mixed credit. Thanks @contributor and @OpenClaw." },
|
|
{ line: 5, handle: "clawsweeper[bot]", text: "- Bot repair. Thanks @clawsweeper[bot]." },
|
|
{ line: 6, handle: "dependabot[bot]", text: "- Dependency bump. Thanks @dependabot[bot]." },
|
|
{ line: 7, handle: "app/clawsweeper", text: "- App repair. Thanks @app/clawsweeper." },
|
|
]);
|
|
});
|
|
|
|
it("allows external contributor thanks attributions", () => {
|
|
expect(
|
|
findForbiddenChangelogThanks(
|
|
"- User-facing fix. Fixes #123. Thanks @external-contributor and @other-user.",
|
|
),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("checks every thanked handle on a changelog line", () => {
|
|
expect(
|
|
findForbiddenChangelogThanks("- Mixed credit (#123). Thanks @openclaw and @alice."),
|
|
).toEqual([
|
|
{
|
|
line: 1,
|
|
handle: "openclaw",
|
|
text: "- Mixed credit (#123). Thanks @openclaw and @alice.",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses one attribution predicate for scanner and shell checks", () => {
|
|
expect(isForbiddenChangelogThanksHandle("")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("null")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("app/any-bot")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("codex")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("openclaw")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("steipete")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("app/clawsweeper")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("clawsweeper")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("clawsweeper[bot]")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("openclaw-clawsweeper")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("openclaw-clawsweeper[bot]")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("dependabot[bot]")).toBe(true);
|
|
expect(isForbiddenChangelogThanksHandle("dependabot[bot]", { strictBotHandle: true })).toBe(
|
|
true,
|
|
);
|
|
expect(isForbiddenChangelogThanksHandle("alice")).toBe(false);
|
|
expect(isForbiddenChangelogThanksHandle("human-clawsweeper-fan")).toBe(false);
|
|
expect(
|
|
isForbiddenChangelogThanksHandle("human-clawsweeper-fan", { strictBotHandle: true }),
|
|
).toBe(false);
|
|
|
|
expect(requiresExplicitHumanChangelogThanks("clawsweeper")).toBe(true);
|
|
expect(requiresExplicitHumanChangelogThanks("clawsweeper[bot]")).toBe(true);
|
|
expect(requiresExplicitHumanChangelogThanks("dependabot[bot]")).toBe(true);
|
|
expect(requiresExplicitHumanChangelogThanks("app/clawsweeper")).toBe(true);
|
|
expect(requiresExplicitHumanChangelogThanks("human-clawsweeper-fan")).toBe(false);
|
|
expect(requiresExplicitHumanChangelogThanks("steipete")).toBe(false);
|
|
expect(requiresExplicitHumanChangelogThanks("")).toBe(false);
|
|
});
|
|
|
|
it("requires explicit human thanks for bot PR changelog entries", () => {
|
|
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123).");
|
|
try {
|
|
let output = "";
|
|
try {
|
|
validateChangelogEntry(repo, "dependabot[bot]");
|
|
} catch (error) {
|
|
output = String((error as { stdout?: unknown }).stdout ?? error);
|
|
}
|
|
expect(output).toContain("must include an explicit human Thanks @handle");
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("accepts explicit human thanks for bot PR changelog entries", () => {
|
|
const repo = createRepoWithPrChangelogDiff("- Bot repair (#123). Thanks @alice.");
|
|
try {
|
|
expect(validateChangelogEntry(repo, "dependabot[bot]")).toContain("explicit thanks");
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps non-bot forbidden contributors on the no-thanks fallback", () => {
|
|
const repo = createRepoWithPrChangelogDiff("- Maintainer repair (#123).");
|
|
try {
|
|
expect(validateChangelogEntry(repo, "steipete")).toContain("skipping thanks check");
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("runs the shell attribution policy over real changelog content", () => {
|
|
const forbiddenRepo = createRepoWithChangelog(
|
|
"# Changelog\n\n## Unreleased\n\n### Fixes\n\n- Bot repair. Thanks @dependabot[bot].\n",
|
|
);
|
|
try {
|
|
let output = "";
|
|
try {
|
|
validateChangelogAttributionPolicy(forbiddenRepo);
|
|
} catch (error) {
|
|
output = String((error as { stderr?: unknown }).stderr ?? error);
|
|
}
|
|
expect(output).toContain("Forbidden changelog thanks attribution");
|
|
expect(output).toContain("CHANGELOG.md:7 uses Thanks @dependabot[bot]");
|
|
} finally {
|
|
rmSync(forbiddenRepo, { recursive: true, force: true });
|
|
}
|
|
|
|
const allowedRepo = createRepoWithChangelog(
|
|
"# Changelog\n\n## Unreleased\n\n### Fixes\n\n- User fix. Thanks @alice.\n",
|
|
);
|
|
try {
|
|
expect(validateChangelogAttributionPolicy(allowedRepo)).toBe("");
|
|
} finally {
|
|
rmSync(allowedRepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects root changelog updates from normal prepare gates", () => {
|
|
const repo = createRepoWithPrChangelogDiff("- User fix (#123). Thanks @alice.");
|
|
const callsPath = path.join(repo, "calls.log");
|
|
mkdirSync(path.join(repo, ".local"));
|
|
writeFileSync(path.join(repo, ".local", "pr-meta.env"), "PR_AUTHOR=alice\n", "utf8");
|
|
try {
|
|
let output = "";
|
|
try {
|
|
run(
|
|
repo,
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`
|
|
set -euo pipefail
|
|
source "$OPENCLAW_PR_COMMON_SH"
|
|
source "$OPENCLAW_PR_CHANGELOG_SH"
|
|
source "$OPENCLAW_PR_GATES_SH"
|
|
|
|
enter_worktree() { :; }
|
|
checkout_prep_branch() { :; }
|
|
bootstrap_deps_if_needed() { :; }
|
|
require_artifact() { [ -s "$1" ]; }
|
|
normalize_pr_changelog_entries() { printf 'normalize\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_attribution_policy() { printf 'policy\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_merge_hygiene() { printf 'merge-hygiene\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_entry_for_pr() { printf 'entry:%s:%s\\n' "$1" "$2" >>"$OPENCLAW_TEST_CALLS"; }
|
|
run_quiet_logged() { printf 'gate:%s\\n' "$1" >>"$OPENCLAW_TEST_CALLS"; }
|
|
|
|
prepare_gates 123
|
|
`,
|
|
],
|
|
{
|
|
OPENCLAW_PR_COMMON_SH: commonScriptPath,
|
|
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
|
OPENCLAW_PR_GATES_SH: gatesScriptPath,
|
|
OPENCLAW_TEST_CALLS: callsPath,
|
|
OPENCLAW_TESTBOX: "0",
|
|
},
|
|
);
|
|
} catch (error) {
|
|
output = commandOutput(error);
|
|
}
|
|
const calls = existsSync(callsPath) ? readFileSync(callsPath, "utf8") : "";
|
|
|
|
expect(output).toContain("CHANGELOG.md is release-owned");
|
|
expect(calls).not.toContain("normalize");
|
|
expect(calls).not.toContain("policy");
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows explicit release automation to run changelog policy from prepare gates", () => {
|
|
const repo = createRepoWithPrChangelogDiff("- User fix (#123). Thanks @alice.");
|
|
const callsPath = path.join(repo, "calls.log");
|
|
mkdirSync(path.join(repo, ".local"));
|
|
writeFileSync(path.join(repo, ".local", "pr-meta.env"), "PR_AUTHOR=alice\n", "utf8");
|
|
try {
|
|
const output = run(
|
|
repo,
|
|
"bash",
|
|
[
|
|
"-c",
|
|
`
|
|
set -euo pipefail
|
|
source "$OPENCLAW_PR_COMMON_SH"
|
|
source "$OPENCLAW_PR_CHANGELOG_SH"
|
|
source "$OPENCLAW_PR_GATES_SH"
|
|
|
|
enter_worktree() { :; }
|
|
checkout_prep_branch() { :; }
|
|
bootstrap_deps_if_needed() { :; }
|
|
require_artifact() { [ -s "$1" ]; }
|
|
normalize_pr_changelog_entries() { printf 'normalize\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_attribution_policy() { printf 'policy\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_merge_hygiene() { printf 'merge-hygiene\\n' >>"$OPENCLAW_TEST_CALLS"; }
|
|
validate_changelog_entry_for_pr() { printf 'entry:%s:%s\\n' "$1" "$2" >>"$OPENCLAW_TEST_CALLS"; }
|
|
run_quiet_logged() { printf 'gate:%s\\n' "$1" >>"$OPENCLAW_TEST_CALLS"; }
|
|
|
|
prepare_gates 123
|
|
`,
|
|
],
|
|
{
|
|
OPENCLAW_ALLOW_ROOT_CHANGELOG_PR: "1",
|
|
OPENCLAW_PR_COMMON_SH: commonScriptPath,
|
|
OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath,
|
|
OPENCLAW_PR_GATES_SH: gatesScriptPath,
|
|
OPENCLAW_TEST_CALLS: callsPath,
|
|
OPENCLAW_TESTBOX: "0",
|
|
},
|
|
);
|
|
const calls = readFileSync(callsPath, "utf8");
|
|
|
|
expect(output).toContain("docs_only=true");
|
|
expect(calls).toContain("normalize\npolicy\n");
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|