Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions .github/workflows/pr-cleanup-guidance.yml
Original file line number Diff line number Diff line change
@@ -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 = "<!-- quantem-pr-cleanup-guidance:v1 -->";
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,
});
}
4 changes: 4 additions & 0 deletions .github/workflows/widget-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand All @@ -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/**"
Expand Down
39 changes: 39 additions & 0 deletions tests/test_repository_pr_hygiene.py
Original file line number Diff line number Diff line change
@@ -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
Loading