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
263 lines
7.9 KiB
Python
Executable File
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())
|