diff --git a/.agents/skills/openclaw-pr-maintainer/SKILL.md b/.agents/skills/openclaw-pr-maintainer/SKILL.md index f217f734c88..f90b6f110e5 100644 --- a/.agents/skills/openclaw-pr-maintainer/SKILL.md +++ b/.agents/skills/openclaw-pr-maintainer/SKILL.md @@ -285,8 +285,10 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \ - Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed. - Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out. - When landing or merging any PR, follow the global `/landpr` process. +- Do not edit `CHANGELOG.md` for routine PR maintenance, review follow-up, conflict repair, contributor-branch preparation, tests, docs, or feature/fix PR refreshes. Changelog edits are release-managed only and require an explicit release/changelog task. - Use `scripts/committer "" ` for scoped commits instead of manual `git add` and `git commit`. - Keep commit messages concise and action-oriented. +- Do not add assistant, agent, or non-Codex coauthor/credit trailers to commits or public PR comments unless Val explicitly asks for that attribution. - Group related changes; avoid bundling unrelated refactors. - Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues. - Do not commit PR-only artifacts such as screenshots under `.github/pr-assets`; attach them to the PR/comment or use an external artifact store instead. diff --git a/AGENTS.md b/AGENTS.md index 2beff581fb5..fa22f0b140a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ Skills own workflows; root owns hard policy and routing. - Missing deps: `pnpm install`, retry once, then report first actionable error. - CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review. - Product/docs/UI/changelog wording: "plugin/plugins"; `extensions/` is internal. +- Routine PR maintenance, review follow-up, conflict repair, and contributor-branch preparation must not edit `CHANGELOG.md`; release generation owns changelog edits. +- Do not add assistant, agent, or non-Codex coauthor/credit trailers on OpenClaw PR commits or public PR comments unless Val explicitly asks for that attribution. - New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels. - New `AGENTS.md`: add sibling `CLAUDE.md` symlink; edit `AGENTS.md` only. diff --git a/scripts/pr b/scripts/pr index ccbe8ca8cad..c851c94bfd1 100755 --- a/scripts/pr +++ b/scripts/pr @@ -58,8 +58,6 @@ source "$script_parent_dir/pr-lib/worktree.sh" # shellcheck disable=SC1091 source "$script_parent_dir/pr-lib/common.sh" # shellcheck disable=SC1091 -source "$script_parent_dir/pr-lib/changelog.sh" -# shellcheck disable=SC1091 source "$script_parent_dir/pr-lib/gates.sh" # shellcheck disable=SC1091 source "$script_parent_dir/pr-lib/push.sh" diff --git a/scripts/pr-lib/changelog.sh b/scripts/pr-lib/changelog.sh deleted file mode 100644 index eaf15880a65..00000000000 --- a/scripts/pr-lib/changelog.sh +++ /dev/null @@ -1,402 +0,0 @@ -changelog_helper_root() { - cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -} - -changelog_attribution_script() { - printf '%s\n' "$(changelog_helper_root)/scripts/check-changelog-attributions.mjs" -} - -normalize_pr_changelog_entries() { - local pr="$1" - local changelog_path="CHANGELOG.md" - - [ -f "$changelog_path" ] || return 0 - - PR_NUMBER_FOR_CHANGELOG="$pr" node <<'EOF_NODE' -const fs = require("node:fs"); - -const pr = process.env.PR_NUMBER_FOR_CHANGELOG; -const path = "CHANGELOG.md"; -const original = fs.readFileSync(path, "utf8"); -const lines = original.split("\n"); -const prPattern = new RegExp(`(?:\\(#${pr}\\)|openclaw#${pr})`, "i"); - -function findActiveSectionIndex(arr) { - const versionUnreleasedIndex = arr.findIndex((line) => - /^##\s+.+\(\s*unreleased\s*\)\s*$/i.test(line.trim()), - ); - if (versionUnreleasedIndex !== -1) { - return versionUnreleasedIndex; - } - return arr.findIndex((line) => line.trim().toLowerCase() === "## unreleased"); -} - -function findSectionEnd(arr, start) { - for (let i = start + 1; i < arr.length; i += 1) { - if (/^## /.test(arr[i])) { - return i; - } - } - return arr.length; -} - -function ensureActiveSection(arr) { - let activeIndex = findActiveSectionIndex(arr); - if (activeIndex !== -1) { - return activeIndex; - } - - let insertAt = arr.findIndex((line, idx) => idx > 0 && /^## /.test(line)); - if (insertAt === -1) { - insertAt = arr.length; - } - - const block = ["## Unreleased", "", "### Changes", ""]; - if (insertAt > 0 && arr[insertAt - 1] !== "") { - block.unshift(""); - } - arr.splice(insertAt, 0, ...block); - return findActiveSectionIndex(arr); -} - -function contextFor(arr, index) { - let major = ""; - let minor = ""; - for (let i = index; i >= 0; i -= 1) { - const line = arr[i]; - if (!minor && /^### /.test(line)) { - minor = line.trim(); - } - if (/^## /.test(line)) { - major = line.trim(); - break; - } - } - return { major, minor }; -} - -function ensureSubsection(arr, subsection) { - const activeIndex = ensureActiveSection(arr); - const activeEnd = findSectionEnd(arr, activeIndex); - const desired = subsection && /^### /.test(subsection) ? subsection : "### Changes"; - for (let i = activeIndex + 1; i < activeEnd; i += 1) { - if (arr[i].trim() === desired) { - return i; - } - } - - let insertAt = activeEnd; - while (insertAt > activeIndex + 1 && arr[insertAt - 1] === "") { - insertAt -= 1; - } - const block = ["", desired, ""]; - arr.splice(insertAt, 0, ...block); - return insertAt + 1; -} - -function sectionTailInsertIndex(arr, subsectionIndex) { - let nextHeading = arr.length; - for (let i = subsectionIndex + 1; i < arr.length; i += 1) { - if (/^### /.test(arr[i]) || /^## /.test(arr[i])) { - nextHeading = i; - break; - } - } - - let insertAt = nextHeading; - while (insertAt > subsectionIndex + 1 && arr[insertAt - 1] === "") { - insertAt -= 1; - } - return insertAt; -} - -const activeHeading = lines[ensureActiveSection(lines)]?.trim() || "## Unreleased"; - -const moved = []; -for (let i = 0; i < lines.length; i += 1) { - if (!prPattern.test(lines[i])) { - continue; - } - const ctx = contextFor(lines, i); - if (ctx.major === activeHeading) { - continue; - } - moved.push({ - line: lines[i], - subsection: ctx.minor || "### Changes", - index: i, - }); -} - -if (moved.length === 0) { - process.exit(0); -} - -const removeIndexes = new Set(moved.map((entry) => entry.index)); -const nextLines = lines.filter((_, idx) => !removeIndexes.has(idx)); - -for (const entry of moved) { - const subsectionIndex = ensureSubsection(nextLines, entry.subsection); - const insertAt = sectionTailInsertIndex(nextLines, subsectionIndex); - - let nextHeading = nextLines.length; - for (let i = subsectionIndex + 1; i < nextLines.length; i += 1) { - if (/^### /.test(nextLines[i]) || /^## /.test(nextLines[i])) { - nextHeading = i; - break; - } - } - - const alreadyPresent = nextLines - .slice(subsectionIndex + 1, nextHeading) - .some((line) => line === entry.line); - if (alreadyPresent) { - continue; - } - nextLines.splice(insertAt, 0, entry.line); -} - -const updated = nextLines.join("\n"); -if (updated !== original) { - fs.writeFileSync(path, updated); -} -EOF_NODE -} - -validate_changelog_attribution_policy() { - node "$(changelog_attribution_script)" CHANGELOG.md -} - -changelog_thanks_required_for_contributor() { - local contrib="${1:-}" - [ -n "$contrib" ] || return 1 - node "$(changelog_attribution_script)" --is-forbidden-handle "$contrib" && return 1 - - return 0 -} - -changelog_explicit_human_thanks_required_for_contributor() { - local contrib="${1:-}" - [ -n "$contrib" ] || return 1 - node "$(changelog_attribution_script)" --requires-explicit-human-thanks "$contrib" -} - -validate_changelog_entry_for_pr() { - local pr="$1" - local contrib="$2" - - local added_lines - added_lines=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md | awk ' - /^\+\+\+/ { next } - /^\+/ { print substr($0, 2) } - ') - - if [ -z "$added_lines" ]; then - echo "CHANGELOG.md is in diff but no added lines were detected." - exit 1 - fi - - local pr_pattern - pr_pattern="(#$pr|openclaw#$pr)" - - local with_pr - with_pr=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" || true) - if [ -z "$with_pr" ]; then - echo "CHANGELOG.md update must reference PR #$pr (for example, (#$pr))." - exit 1 - fi - - local diff_file - diff_file=$(mktemp) - git diff --unified=0 origin/main...HEAD -- CHANGELOG.md > "$diff_file" - - if ! awk -v pr_pattern="$pr_pattern" ' -BEGIN { - line_no = 0 - file_line_count = 0 - issue_count = 0 -} -FNR == NR { - if ($0 ~ /^@@ /) { - if (match($0, /\+[0-9]+/)) { - line_no = substr($0, RSTART + 1, RLENGTH - 1) + 0 - } else { - line_no = 0 - } - next - } - if ($0 ~ /^\+\+\+/) { - next - } - if ($0 ~ /^\+/) { - if (line_no > 0) { - added[line_no] = 1 - added_text = substr($0, 2) - if (added_text ~ pr_pattern) { - pr_added_lines[++pr_added_count] = line_no - pr_added_text[line_no] = added_text - } - line_no++ - } - next - } - if ($0 ~ /^-/) { - next - } - if (line_no > 0) { - line_no++ - } - next -} -{ - changelog[FNR] = $0 - file_line_count = FNR -} -END { - active_release_line = 0 - bare_release_line = 0 - active_release_name = "unreleased" - for (i = 1; i <= file_line_count; i++) { - if (changelog[i] !~ /^## /) { - continue - } - heading = tolower(changelog[i]) - if (heading ~ /^##[[:space:]]+.+\([[:space:]]*unreleased[[:space:]]*\)[[:space:]]*$/) { - active_release_line = i - active_release_name = changelog[i] - break - } - if (heading == "## unreleased" && bare_release_line == 0) { - bare_release_line = i - } - } - if (active_release_line == 0 && bare_release_line != 0) { - active_release_line = bare_release_line - active_release_name = changelog[bare_release_line] - } - - for (idx = 1; idx <= pr_added_count; idx++) { - entry_line = pr_added_lines[idx] - release_line = 0 - section_line = 0 - for (i = entry_line; i >= 1; i--) { - if (section_line == 0 && changelog[i] ~ /^### /) { - section_line = i - continue - } - if (changelog[i] ~ /^## /) { - release_line = i - break - } - } - if (release_line == 0 || release_line != active_release_line) { - printf "CHANGELOG.md PR-linked entry must be in %s: line %d: %s\n", active_release_name, entry_line, pr_added_text[entry_line] - issue_count++ - continue - } - if (section_line == 0) { - printf "CHANGELOG.md entry must be inside a subsection (### ...): line %d: %s\n", entry_line, pr_added_text[entry_line] - issue_count++ - continue - } - - section_name = changelog[section_line] - next_heading = file_line_count + 1 - for (i = entry_line + 1; i <= file_line_count; i++) { - if (changelog[i] ~ /^### / || changelog[i] ~ /^## /) { - next_heading = i - break - } - } - - for (i = entry_line + 1; i < next_heading; i++) { - line_text = changelog[i] - if (line_text ~ /^[[:space:]]*$/) { - continue - } - if (i in added) { - continue - } - printf "CHANGELOG.md PR-linked entry must be appended at the end of section %s: line %d: %s\n", section_name, entry_line, pr_added_text[entry_line] - printf "Found existing non-added line below it at line %d: %s\n", i, line_text - issue_count++ - break - } - } - - if (issue_count > 0) { - print "Move this PR changelog entry to the end of its section (just before the next heading)." - exit 1 - } -} -' "$diff_file" CHANGELOG.md; then - rm -f "$diff_file" - exit 1 - fi - rm -f "$diff_file" - echo "changelog placement validated: PR-linked entries are appended at section tail" - - if changelog_thanks_required_for_contributor "$contrib"; then - local with_pr_and_thanks - with_pr_and_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Fi "thanks @$contrib" || true) - if [ -z "$with_pr_and_thanks" ]; then - echo "CHANGELOG.md update must include both PR #$pr and thanks @$contrib on the changelog entry line." - exit 1 - fi - echo "changelog validated: found PR #$pr + thanks @$contrib" - return 0 - fi - - if ! changelog_explicit_human_thanks_required_for_contributor "$contrib"; then - echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)" - return 0 - fi - - local with_pr_and_any_thanks - with_pr_and_any_thanks=$(printf '%s\n' "$added_lines" | grep -Ein "$pr_pattern" | grep -Ei '(^|[[:space:]])thanks[[:space:]]+@' || true) - if [ -z "$with_pr_and_any_thanks" ]; then - echo "CHANGELOG.md update for bot/app/non-creditable author $contrib must include an explicit human Thanks @handle on the PR #$pr entry line." - echo "Choose the credited original contributor, or stop for maintainer input if authorship is unclear." - exit 1 - fi - - echo "changelog validated: found PR #$pr + explicit thanks for bot/app/non-creditable author $contrib" -} - -validate_changelog_merge_hygiene() { - local diff - diff=$(git diff --unified=0 origin/main...HEAD -- CHANGELOG.md) - - local removed_lines - removed_lines=$(printf '%s\n' "$diff" | awk ' - /^---/ { next } - /^-/ { print substr($0, 2) } - ') - if [ -z "$removed_lines" ]; then - return 0 - fi - - local removed_refs - removed_refs=$(printf '%s\n' "$removed_lines" | grep -Eo '#[0-9]+' | sort -u || true) - if [ -z "$removed_refs" ]; then - return 0 - fi - - local added_lines - added_lines=$(printf '%s\n' "$diff" | awk ' - /^\+\+\+/ { next } - /^\+/ { print substr($0, 2) } - ') - - local ref - while IFS= read -r ref; do - [ -z "$ref" ] && continue - if ! printf '%s\n' "$added_lines" | grep -Fq "$ref"; then - echo "CHANGELOG.md drops existing entry reference $ref without re-adding it." - echo "Likely merge conflict loss; restore the dropped entry (or keep the same PR ref in rewritten text)." - exit 1 - fi - done <<<"$removed_refs" - - echo "changelog merge hygiene validated: no dropped PR references" -} diff --git a/scripts/pr-lib/common.sh b/scripts/pr-lib/common.sh index 22c58ceccec..1d8733956ee 100644 --- a/scripts/pr-lib/common.sh +++ b/scripts/pr-lib/common.sh @@ -177,32 +177,6 @@ merge_author_email_candidates() { "${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++' } -pr_contributor_allows_human_trailers() { - local contrib="${1:-}" - local normalized - normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]') - - case "$normalized" in - ""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete") - return 1 - ;; - esac - - return 0 -} - -resolve_contributor_coauthor_email() { - local contrib="${1:-}" - - if ! pr_contributor_allows_human_trailers "$contrib"; then - return 1 - fi - - local contrib_id - contrib_id=$(gh api "users/$contrib" --jq .id) || return 1 - printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib" -} - common_repo_root() { if command -v repo_root >/dev/null 2>&1; then repo_root diff --git a/scripts/pr-lib/gates.sh b/scripts/pr-lib/gates.sh index ffae1fd745e..6ae0a3a0af3 100644 --- a/scripts/pr-lib/gates.sh +++ b/scripts/pr-lib/gates.sh @@ -56,22 +56,16 @@ prepare_gates() { if [ -n "$unsupported_changelog_fragments" ]; then echo "Unsupported changelog fragment files detected:" printf '%s\n' "$unsupported_changelog_fragments" - echo "Move changelog fragment content into CHANGELOG.md and remove changelog/fragments files." + echo "Remove changelog/fragments files. OpenClaw changelog edits are release-managed only." exit 1 fi if [ "$has_changelog_update" = "true" ]; then - normalize_pr_changelog_entries "$pr" - validate_changelog_attribution_policy + echo "CHANGELOG.md changes are release-managed only. Remove CHANGELOG.md from this PR unless this is an explicit release/changelog task." + exit 1 fi - if [ "$changelog_required" = "true" ]; then - local contrib="${PR_AUTHOR:-}" - validate_changelog_merge_hygiene - validate_changelog_entry_for_pr "$pr" "$contrib" - else - echo "Changelog not required for this changed-file set." - fi + echo "Changelog not required for this changed-file set." local current_head current_head=$(git rev-parse HEAD) @@ -96,7 +90,7 @@ prepare_gates() { if [ "$reuse_gates" = "true" ]; then gates_mode="reused_docs_only" - echo "Docs/changelog-only delta since last verified head $previous_last_verified_head; reusing prior gates." + echo "Docs-only delta since last verified head $previous_last_verified_head; reusing prior gates." else run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check diff --git a/scripts/pr-lib/merge.sh b/scripts/pr-lib/merge.sh index 09f36109620..98e5f5aaff6 100644 --- a/scripts/pr-lib/merge.sh +++ b/scripts/pr-lib/merge.sh @@ -183,8 +183,6 @@ merge_run() { pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title) local pr_number pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number) - local contrib - contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login) local is_draft is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft) if [ "$is_draft" = "true" ]; then @@ -197,15 +195,6 @@ merge_run() { local reviewer_id reviewer_id=$(gh api user --jq .id) - local contrib_coauthor_email="${COAUTHOR_EMAIL:-}" - if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then - if contrib_coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then - : - else - contrib_coauthor_email="" - fi - fi - local reviewer_email_candidates=() local reviewer_email_candidate while IFS= read -r reviewer_email_candidate; do @@ -218,17 +207,11 @@ merge_run() { fi local reviewer_email="${reviewer_email_candidates[0]}" - local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com" { echo "Merged via squash." echo echo "Prepared head SHA: $PREP_HEAD_SHA" - if [ -n "$contrib_coauthor_email" ]; then - echo "Co-authored-by: $contrib <$contrib_coauthor_email>" - fi - echo "Co-authored-by: $reviewer <$reviewer_coauthor_email>" - echo "Reviewed-by: @$reviewer" } > .local/merge-body.txt delete_remote_pr_head_branch_after_merge() { @@ -349,15 +332,6 @@ merge_run() { exit 1 fi - local commit_body - commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message) - if [ -n "$contrib_coauthor_email" ]; then - printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; } - else - echo "Skipping PR author co-author trailer check for bot/app author $contrib." - fi - printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; } - local ok=0 local comment_output="" local attempt @@ -368,10 +342,6 @@ merge_run() { echo echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)" echo "- Merge commit: [$merge_sha]($merge_sha_url)" - if pr_contributor_allows_human_trailers "$contrib"; then - echo - echo "Thanks @$contrib!" - fi } | gh pr comment "$pr" -F - 2>&1 ); then ok=1 diff --git a/scripts/pr-lib/prepare-core.sh b/scripts/pr-lib/prepare-core.sh index ac21111feaf..ceb5e5b38f5 100644 --- a/scripts/pr-lib/prepare-core.sh +++ b/scripts/pr-lib/prepare-core.sh @@ -163,12 +163,6 @@ prepare_push() { if [ -z "$contrib" ]; then contrib=$(gh pr view "$pr" --json author --jq .author.login) fi - local coauthor_email="" - if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then - : - else - coauthor_email="" - fi cat >> .local/prep.md < .local/prep.env ls -la .local/prep.md .local/prep.env >/dev/null @@ -240,12 +233,6 @@ prepare_sync_head() { if [ -z "$contrib" ]; then contrib=$(gh pr view "$pr" --json author --jq .author.login) fi - local coauthor_email="" - if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then - : - else - coauthor_email="" - fi cat >> .local/prep.md < .local/prep.env ls -la .local/prep.md .local/prep.env >/dev/null diff --git a/test/scripts/check-changelog-attributions.test.ts b/test/scripts/check-changelog-attributions.test.ts index 367834bad11..a69fe2aaaca 100644 --- a/test/scripts/check-changelog-attributions.test.ts +++ b/test/scripts/check-changelog-attributions.test.ts @@ -1,6 +1,6 @@ // Check Changelog Attributions tests cover check changelog attributions script behavior. import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -10,7 +10,6 @@ import { 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"); @@ -52,32 +51,6 @@ function createRepoWithChangelog(content: string): string { 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 = [ @@ -152,47 +125,16 @@ describe("check-changelog-attributions", () => { 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", () => { + it("runs the attribution policy CLI 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); + run(forbiddenRepo, "node", [ + path.join(process.cwd(), "scripts/check-changelog-attributions.mjs"), + ]); } catch (error) { output = String((error as { stderr?: unknown }).stderr ?? error); } @@ -206,53 +148,52 @@ describe("check-changelog-attributions", () => { "# Changelog\n\n## Unreleased\n\n### Fixes\n\n- User fix. Thanks @alice.\n", ); try { - expect(validateChangelogAttributionPolicy(allowedRepo)).toBe(""); + expect( + run(allowedRepo, "node", [ + path.join(process.cwd(), "scripts/check-changelog-attributions.mjs"), + ]), + ).toBe(""); } finally { rmSync(allowedRepo, { recursive: true, force: true }); } }); - it("runs changelog attribution policy from prepare gates when CHANGELOG changes", () => { + it("rejects changelog changes 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", - ` + 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"; } +run_quiet_logged() { echo "unexpected gate: $1"; exit 99; } prepare_gates 123 `, - ], - { - OPENCLAW_PR_COMMON_SH: commonScriptPath, - OPENCLAW_PR_CHANGELOG_SH: changelogScriptPath, - OPENCLAW_PR_GATES_SH: gatesScriptPath, - OPENCLAW_TEST_CALLS: callsPath, - }, - ); - const calls = readFileSync(callsPath, "utf8"); + ], + { + OPENCLAW_PR_COMMON_SH: commonScriptPath, + OPENCLAW_PR_GATES_SH: gatesScriptPath, + }, + ); + } catch (error) { + output = String((error as { stdout?: unknown }).stdout ?? error); + } - expect(output).toContain("docs_only=true"); - expect(calls).toContain("normalize\npolicy\n"); + expect(output).toContain("CHANGELOG.md changes are release-managed only"); } finally { rmSync(repo, { recursive: true, force: true }); }