From 409095bbe1e9ca66c5c1d972e401799868767f3d Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Wed, 12 Aug 2026 21:49:49 -0700 Subject: [PATCH] ci: add PR cleanup guidance --- .github/PULL_REQUEST_TEMPLATE.md | 5 ++ .github/workflows/pr-cleanup-guidance.yml | 89 +++++++++++++++++++++++ .github/workflows/widget-ci.yml | 4 + tests/test_repository_pr_hygiene.py | 39 ++++++++++ 4 files changed, 137 insertions(+) create mode 100644 .github/workflows/pr-cleanup-guidance.yml create mode 100644 tests/test_repository_pr_hygiene.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f2678903..a5edd501 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,6 +35,11 @@ evidence in the three visible sections above. - [ ] Before committing, inspect `git status --short` and `git diff --stat`; do not commit generated HTML, docs builds, screenshots, local notebooks, private data, or machine-specific notes. +- [ ] Scan the committed diff and the PR title/body for unintended private + information: personal names or usernames, private email addresses, absolute + machine paths, hostnames, credentials or tokens, and private sample or + dataset identifiers. Keep public attribution or provenance only when it is + intentional and approved. - [ ] Committed notebooks carry NO baked widget state (`metadata.widgets`) and pass `scripts/check_notebook_sizes.py`. The docs CI executes tutorials at build time (`execute_notebooks: force` in `docs/_config.yml`) and bakes diff --git a/.github/workflows/pr-cleanup-guidance.yml b/.github/workflows/pr-cleanup-guidance.yml new file mode 100644 index 00000000..38ee41f8 --- /dev/null +++ b/.github/workflows/pr-cleanup-guidance.yml @@ -0,0 +1,89 @@ +name: pr cleanup guidance + +on: + pull_request_target: + types: [opened, reopened, closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + guidance: + runs-on: ubuntu-latest + steps: + - name: Create or update cleanup guidance + uses: actions/github-script@v8 + with: + script: | + const marker = ""; + const pr = context.payload.pull_request; + const action = context.payload.action; + const headRepository = pr.head.repo?.full_name ?? "unknown"; + const headBranch = pr.head.ref; + const baseRepository = pr.base.repo.full_name; + const baseBranch = pr.base.ref; + const code = (value) => `\`${String(value).replaceAll("`", "\\`")}\``; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }, + ); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.includes(marker), + ); + + if (action === "closed" && !pr.merged) { + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + return; + } + + let tasks; + if (pr.merged) { + tasks = [ + `Delete ${code(`${headRepository}:${headBranch}`)} from its remote if you own it, it is not long-lived, and no other PR uses it.`, + `Remove the dedicated worktree and local ${code(headBranch)} branch after confirming the worktree is clean.`, + `Fetch ${code(baseRepository)} and update local ${code(baseBranch)} before starting new work.`, + ]; + } else { + tasks = [ + `Keep ${code(`${headRepository}:${headBranch}`)} until this PR is merged.`, + `After merge, delete ${code(`${headRepository}:${headBranch}`)} if you own it and no other PR uses it.`, + `Then remove its dedicated worktree and local ${code(headBranch)} branch and update local ${code(baseBranch)}.`, + ]; + } + + const body = [ + marker, + ...tasks.map((task) => `- ${task}`), + ].join("\n"); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + } diff --git a/.github/workflows/widget-ci.yml b/.github/workflows/widget-ci.yml index b07c7fa6..035e53bd 100644 --- a/.github/workflows/widget-ci.yml +++ b/.github/workflows/widget-ci.yml @@ -3,6 +3,8 @@ name: widget ci on: pull_request: paths: + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/workflows/pr-cleanup-guidance.yml" - ".github/workflows/widget-ci.yml" - "docs/**" - "js/**" @@ -15,6 +17,8 @@ on: push: branches: [main] paths: + - ".github/PULL_REQUEST_TEMPLATE.md" + - ".github/workflows/pr-cleanup-guidance.yml" - ".github/workflows/widget-ci.yml" - "docs/**" - "js/**" diff --git a/tests/test_repository_pr_hygiene.py b/tests/test_repository_pr_hygiene.py new file mode 100644 index 00000000..eb7d4500 --- /dev/null +++ b/tests/test_repository_pr_hygiene.py @@ -0,0 +1,39 @@ +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def test_pr_cleanup_guidance_is_safe_and_agent_readable() -> None: + workflow = (REPO / ".github/workflows/pr-cleanup-guidance.yml").read_text() + ci_workflow = (REPO / ".github/workflows/widget-ci.yml").read_text() + + assert "pull_request_target:" in workflow + assert "types: [opened, reopened, closed]" in workflow + assert "issues: write" in workflow + assert "actions/checkout" not in workflow + assert "quantem-pr-cleanup-guidance:v1" in workflow + assert "Agent-readable" not in workflow + assert "JSON.stringify" not in workflow + assert "### Repository cleanup" not in workflow + assert "tasks.map((task) => `- ${task}`)" in workflow + assert "pr.merged" in workflow + assert 'action === "closed" && !pr.merged' in workflow + assert "issues.deleteComment" in workflow + assert ci_workflow.count('".github/PULL_REQUEST_TEMPLATE.md"') == 2 + assert ci_workflow.count('".github/workflows/pr-cleanup-guidance.yml"') == 2 + + +def test_pr_template_requires_private_information_preflight() -> None: + template = (REPO / ".github/PULL_REQUEST_TEMPLATE.md").read_text() + normalized = " ".join(template.split()) + + for required in ( + "personal names or usernames", + "private email addresses", + "absolute machine paths", + "hostnames", + "credentials or tokens", + "private sample or dataset identifiers", + ): + assert required in normalized