Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

263 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Nudge contributors to update PR description and changelog fragments.
Always exits 0 (non-blocking). Fires when:
- We are on a PR feature branch (detected via ``gh pr status``)
- Substantial source changes are staged (>= MIN_SOURCE_ADDED lines)
- The PR description appears stale or is still the default template
Silently exits when:
- ``gh`` CLI is not installed or not authenticated
- Not on a feature branch (e.g., on main)
- No substantial source changes staged
- PR description was updated recently
"""
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _commit_analysis import analyze_commit # noqa: E402
# Minimum added source lines before the nudge fires
MIN_SOURCE_ADDED = 20
# Higher threshold for fragment-only nudge (big changes may need a
# changelog fragment even if the PR description is already fresh).
BIG_CHANGE_LINES = 60
# Grace period: if PR was updated within this many minutes, assume fresh.
PR_FRESHNESS_MINUTES = 60
# Default PR template body prefix (triggers staleness nudge regardless of
# timestamps).
_DEFAULT_BODY_PREFIXES = (
"## Description\n\nFixes #",
"## Description\r\n\r\nFixes #",
)
def _run_gh(args):
"""Run a ``gh`` command and return stdout, or None on any failure."""
try:
result = subprocess.run(
["gh"] + args,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return None
return result.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _get_pr_context():
"""Return PR context dict or None if not on a PR feature branch.
Uses ``gh pr status --json number,title,body,updatedAt,headRefName``.
The ``currentBranch`` key is a single object (or null) — not an array.
"""
output = _run_gh(
[
"pr",
"status",
"--json",
"number,title,body,updatedAt,headRefName",
]
)
if not output:
return None
try:
data = json.loads(output)
except json.JSONDecodeError:
return None
current = data.get("currentBranch")
if not current or not isinstance(current, dict):
return None
head = current.get("headRefName", "")
# On main/master there may be a stale sync PR — skip.
if head in ("main", "master", ""):
return None
return {
"number": current.get("number"),
"title": current.get("title", ""),
"body": current.get("body", ""),
"updated_at": current.get("updatedAt", ""),
}
def _is_pr_description_stale(pr_context):
"""Return True if the PR description is likely stale.
Stale conditions (any triggers):
1. Body matches the default template (``## Description\\n\\nFixes #``)
2. Body is empty
3. PR updatedAt is older than the branch tip commit by > PR_FRESHNESS_MINUTES
"""
body = pr_context["body"].strip()
# Condition 1 & 2: empty or default template
if not body or body.startswith(_DEFAULT_BODY_PREFIXES):
return True
# Condition 3: timestamp-based staleness
updated_str = pr_context.get("updated_at", "")
if not updated_str:
return True # Can't determine freshness — nudge
try:
pr_updated = datetime.fromisoformat(updated_str.replace("Z", "+00:00"))
except (ValueError, TypeError):
return True
try:
result = subprocess.run(
["git", "log", "-1", "--format=%aI"],
capture_output=True,
text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return False # Can't determine — don't nudge
last_commit = datetime.fromisoformat(
result.stdout.strip().replace("Z", "+00:00")
)
except (ValueError, TypeError):
return False
# Normalise both to UTC for comparison
if last_commit.tzinfo is None:
last_commit = last_commit.replace(tzinfo=timezone.utc)
if pr_updated.tzinfo is None:
pr_updated = pr_updated.replace(tzinfo=timezone.utc)
staleness = last_commit - pr_updated
return staleness > timedelta(minutes=PR_FRESHNESS_MINUTES)
def _find_existing_fragments(pr_num):
"""Return list of Paths to existing changelog.d/ fragments for this PR.
Fragments follow towncrier naming: ``<id>.<category>[.<n>].md``.
"""
frag_dir = Path(__file__).resolve().parent.parent / "changelog.d"
if not frag_dir.is_dir():
return []
prefix = f"{pr_num}."
return sorted(
f
for f in frag_dir.glob("*.md")
if f.name.startswith(prefix) and f.name != "README.md"
)
def _fragments_staged():
"""Return True if any fragment under changelog.d/ is staged."""
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--", "changelog.d/"],
capture_output=True,
text=True,
)
files = [line for line in result.stdout.strip().splitlines() if line]
return any(f.endswith(".md") and Path(f).name != "README.md" for f in files)
def main():
analysis = analyze_commit()
# Silent exit: no source files staged
if not analysis.source_files:
return 0
# Silent exit: trivial change
if analysis.total_source_added < MIN_SOURCE_ADDED:
return 0
# Silent exit: not on a PR feature branch (or gh unavailable)
pr_context = _get_pr_context()
if pr_context is None:
return 0
pr_num = pr_context["number"]
pr_title = pr_context["title"]
body = pr_context["body"].strip()
is_default = not body or body.startswith(_DEFAULT_BODY_PREFIXES)
desc_stale = _is_pr_description_stale(pr_context)
notes_stale = not _fragments_staged() and (
desc_stale or analysis.total_source_added >= BIG_CHANGE_LINES
)
# Nothing to nudge about
if not desc_stale and not notes_stale:
return 0
# Build nudge output
print()
print(" \033[36mPR Description Reminder\033[0m")
print(" " + "-" * 40)
print(
f" You're adding {analysis.total_source_added} lines across "
f"{len(analysis.source_files)} source file(s)"
)
print(f" on PR #{pr_num}: {pr_title}")
print()
# PR description nudge
if desc_stale:
if is_default:
print(
" \033[33mPR description is still the default template.\033[0m"
)
print(" Update it with:")
print(f" gh pr edit {pr_num}")
else:
print(" \033[33mPR description may be stale\033[0m")
print(" (not updated since the branch moved forward).")
print(" Refresh it with:")
print(f" gh pr edit {pr_num}")
# Fragment nudge — fires for stale descriptions OR big changes
if notes_stale:
existing = _find_existing_fragments(pr_num)
if existing:
print()
print(
" \033[33mExisting changelog.d/ fragments for this PR"
" (not staged):\033[0m"
)
for frag in existing:
print(f" - changelog.d/{frag.name}")
print(" Consider updating them if the approach has changed.")
else:
print()
print(" \033[33mNo changelog.d/ fragment for this PR.\033[0m")
print(f" Consider adding changelog.d/{pr_num}.<category>.md")
print(
" (categories: breaking, security, feature, bugfix,"
" removal, misc)."
)
print(" See changelog.d/README.md for conventions.")
if analysis.total_source_added >= BIG_CHANGE_LINES:
print(
f" This is a large change ({analysis.total_source_added}"
" lines) — strongly consider adding a fragment."
)
print()
return 0
if __name__ == "__main__":
sys.exit(main())