Sync from dev @ 252c1cf
Source: main (252c1cf) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
316
scripts/apply_reviewer_rejections.py
Normal file
316
scripts/apply_reviewer_rejections.py
Normal file
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply per-policy reviewer reject decisions on rolling drift PRs.
|
||||
|
||||
Reviewer decision format inside auto Change Needed threads:
|
||||
- /reject -> remove this file-level drift from rolling PR (reset to baseline)
|
||||
- /accept -> keep this file-level drift
|
||||
|
||||
Latest decision command in the thread wins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# common.py lives in the same directory; ensure it can be imported when the
|
||||
# script is executed directly.
|
||||
_sys_path_inserted = False
|
||||
if __file__:
|
||||
_script_dir = str(Path(__file__).resolve().parent)
|
||||
if _script_dir not in sys.path:
|
||||
sys.path.insert(0, _script_dir)
|
||||
_sys_path_inserted = True
|
||||
|
||||
import common
|
||||
|
||||
if _sys_path_inserted:
|
||||
sys.path.pop(0)
|
||||
|
||||
_request_json = common.request_json
|
||||
_run_git = common.run_git
|
||||
_configure_git_identity = common.configure_git_identity
|
||||
|
||||
AUTO_TICKET_THREAD_PREFIX = "AUTO-CHANGE-TICKET:"
|
||||
THREAD_STATUS_FIXED = 2
|
||||
THREAD_STATUS_WONT_FIX = 3
|
||||
THREAD_STATUS_CLOSED = 4
|
||||
THREAD_STATUS_BY_DESIGN = 5
|
||||
DECISION_RE = re.compile(r"(?im)^\s*(?:/|#)?(?P<decision>reject|accept)\b")
|
||||
|
||||
|
||||
def _run_diff_name_only(repo_root: str, baseline_branch: str, drift_branch: str) -> str:
|
||||
three_dot = f"origin/{baseline_branch}...origin/{drift_branch}"
|
||||
two_dot = f"origin/{baseline_branch}..origin/{drift_branch}"
|
||||
try:
|
||||
return _run_git(repo_root, ["diff", "--name-only", three_dot])
|
||||
except RuntimeError as exc:
|
||||
stderr = str(exc).lower()
|
||||
if "no merge base" not in stderr:
|
||||
raise
|
||||
print(
|
||||
"WARNING: No merge base for rolling branches "
|
||||
f"(origin/{baseline_branch}, origin/{drift_branch}); using direct diff."
|
||||
)
|
||||
return _run_git(repo_root, ["diff", "--name-only", two_dot])
|
||||
|
||||
|
||||
def _git_path_exists(repo_root: str, treeish: str, path: str) -> bool:
|
||||
proc = subprocess.run(
|
||||
["git", "cat-file", "-e", f"{treeish}:{path}"],
|
||||
cwd=repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def _normalize_branch_name(branch: str) -> str:
|
||||
b = branch.strip()
|
||||
if b.startswith("refs/heads/"):
|
||||
return b[len("refs/heads/") :]
|
||||
return b
|
||||
|
||||
|
||||
def _thread_status_code(thread: dict[str, Any]) -> int:
|
||||
status = thread.get("status")
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
if isinstance(status, str):
|
||||
mapping = {
|
||||
"fixed": THREAD_STATUS_FIXED,
|
||||
"wontfix": THREAD_STATUS_WONT_FIX,
|
||||
"closed": THREAD_STATUS_CLOSED,
|
||||
"bydesign": THREAD_STATUS_BY_DESIGN,
|
||||
}
|
||||
return mapping.get(status.strip().lower(), 1)
|
||||
return 1
|
||||
|
||||
|
||||
def _is_thread_resolved(thread: dict[str, Any]) -> bool:
|
||||
return _thread_status_code(thread) in (
|
||||
THREAD_STATUS_FIXED,
|
||||
THREAD_STATUS_WONT_FIX,
|
||||
THREAD_STATUS_CLOSED,
|
||||
THREAD_STATUS_BY_DESIGN,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_path_from_content(content: str) -> str | None:
|
||||
marker_re = re.compile(r"<!--\s*" + re.escape(AUTO_TICKET_THREAD_PREFIX) + r"(?P<id>[A-Za-z0-9_-]+)\s*-->")
|
||||
match = marker_re.search(content or "")
|
||||
if not match:
|
||||
return None
|
||||
encoded = match.group("id")
|
||||
padding = "=" * ((4 - len(encoded) % 4) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode((encoded + padding).encode("ascii")).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_doc_like(path: str) -> bool:
|
||||
lp = path.lower()
|
||||
return lp.endswith(".md") or lp.endswith(".markdown") or "/docs/" in lp
|
||||
|
||||
|
||||
def _is_report_like(path: str) -> bool:
|
||||
lp = path.lower()
|
||||
return "/reports/" in lp or "assignment report" in lp
|
||||
|
||||
|
||||
def _latest_thread_decision(comments: list[dict[str, Any]]) -> str | None:
|
||||
decision: str | None = None
|
||||
|
||||
def _comment_sort_key(c: dict[str, Any]) -> tuple[int, int]:
|
||||
try:
|
||||
cid = int(c.get("id", 0))
|
||||
except Exception:
|
||||
cid = 0
|
||||
try:
|
||||
parent = int(c.get("parentCommentId", 0))
|
||||
except Exception:
|
||||
parent = 0
|
||||
return (cid, parent)
|
||||
|
||||
for comment in sorted(comments, key=_comment_sort_key):
|
||||
content = str(comment.get("content", "") or "")
|
||||
match = DECISION_RE.search(content)
|
||||
if match:
|
||||
decision = match.group("decision").lower()
|
||||
return decision
|
||||
|
||||
|
||||
def _post_thread_comment(repo_api: str, pr_id: int, thread_id: int, token: str, content: str) -> None:
|
||||
_request_json(
|
||||
f"{repo_api}/pullrequests/{pr_id}/threads/{thread_id}/comments?api-version=7.1",
|
||||
token=token,
|
||||
method="POST",
|
||||
body={
|
||||
"parentCommentId": 0,
|
||||
"content": content,
|
||||
"commentType": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply reviewer /reject decisions for rolling PR threads")
|
||||
parser.add_argument("--repo-root", required=True)
|
||||
parser.add_argument("--workload", required=True)
|
||||
parser.add_argument("--drift-branch", required=True)
|
||||
parser.add_argument("--baseline-branch", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("SYSTEM_ACCESSTOKEN", "").strip()
|
||||
if not token:
|
||||
raise SystemExit("SYSTEM_ACCESSTOKEN is empty.")
|
||||
|
||||
collection_uri = os.environ["SYSTEM_COLLECTIONURI"].rstrip("/")
|
||||
project = os.environ["SYSTEM_TEAMPROJECT"]
|
||||
repository_id = os.environ["BUILD_REPOSITORY_ID"]
|
||||
|
||||
drift_branch = _normalize_branch_name(args.drift_branch)
|
||||
baseline_branch = _normalize_branch_name(args.baseline_branch)
|
||||
|
||||
repo_api = f"{collection_uri}/{project}/_apis/git/repositories/{repository_id}"
|
||||
source_ref = f"refs/heads/{drift_branch}"
|
||||
target_ref = f"refs/heads/{baseline_branch}"
|
||||
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"searchCriteria.status": "active",
|
||||
"searchCriteria.sourceRefName": source_ref,
|
||||
"searchCriteria.targetRefName": target_ref,
|
||||
"api-version": "7.1",
|
||||
},
|
||||
quote_via=urllib.parse.quote,
|
||||
safe="/",
|
||||
)
|
||||
payload = _request_json(f"{repo_api}/pullrequests?{query}", token=token)
|
||||
prs = payload.get("value", []) if isinstance(payload, dict) else []
|
||||
if not prs:
|
||||
print("No active rolling PR found; skipping reviewer reject sync.")
|
||||
return 0
|
||||
|
||||
pr = prs[0]
|
||||
pr_id = int(pr.get("pullRequestId"))
|
||||
|
||||
_run_git(args.repo_root, ["fetch", "--quiet", "origin", baseline_branch, drift_branch])
|
||||
diff_paths = _run_diff_name_only(args.repo_root, baseline_branch, drift_branch)
|
||||
changed_paths = {
|
||||
p.strip()
|
||||
for p in diff_paths.splitlines()
|
||||
if p.strip() and not _is_doc_like(p.strip()) and not _is_report_like(p.strip())
|
||||
}
|
||||
if not changed_paths:
|
||||
print("No changed policy paths in rolling PR; nothing to auto-reject.")
|
||||
return 0
|
||||
|
||||
threads_payload = _request_json(f"{repo_api}/pullrequests/{pr_id}/threads?api-version=7.1", token=token)
|
||||
threads = threads_payload.get("value", []) if isinstance(threads_payload, dict) else []
|
||||
|
||||
rejections: list[tuple[str, int]] = []
|
||||
examined_ticket_threads = 0
|
||||
for thread in threads:
|
||||
comments = thread.get("comments", []) if isinstance(thread.get("comments"), list) else []
|
||||
marker_path: str | None = None
|
||||
for c in comments:
|
||||
marker_path = _ticket_path_from_content(str(c.get("content", "") or ""))
|
||||
if marker_path:
|
||||
break
|
||||
if not marker_path:
|
||||
continue
|
||||
examined_ticket_threads += 1
|
||||
if marker_path not in changed_paths:
|
||||
continue
|
||||
|
||||
decision = _latest_thread_decision(comments)
|
||||
if decision == "reject":
|
||||
try:
|
||||
thread_id = int(thread.get("id"))
|
||||
except Exception:
|
||||
thread_id = -1
|
||||
rejections.append((marker_path, thread_id))
|
||||
|
||||
if not rejections:
|
||||
print(
|
||||
"No /reject decisions found in auto policy threads "
|
||||
f"(examined={examined_ticket_threads}, changed_paths={len(changed_paths)})."
|
||||
)
|
||||
return 0
|
||||
|
||||
print(
|
||||
"Detected /reject decisions in auto policy threads: "
|
||||
f"{len(rejections)} (examined={examined_ticket_threads})."
|
||||
)
|
||||
|
||||
_run_git(args.repo_root, ["checkout", "--quiet", "--force", "-B", drift_branch, f"origin/{drift_branch}"])
|
||||
|
||||
changed = 0
|
||||
baseline_tree = f"origin/{baseline_branch}"
|
||||
for path, _thread_id in sorted(set(rejections)):
|
||||
if _git_path_exists(args.repo_root, baseline_tree, path):
|
||||
_run_git(args.repo_root, ["checkout", baseline_tree, "--", path])
|
||||
_run_git(args.repo_root, ["add", "--", path])
|
||||
changed += 1
|
||||
else:
|
||||
file_abs = os.path.join(args.repo_root, path)
|
||||
if os.path.exists(file_abs):
|
||||
_run_git(args.repo_root, ["rm", "-f", "--", path])
|
||||
changed += 1
|
||||
|
||||
proc = subprocess.run(
|
||||
["git", "diff", "--cached", "--quiet"],
|
||||
cwd=args.repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
print("Reviewer /reject decisions found, but no effective diff remained after baseline reset.")
|
||||
return 0
|
||||
|
||||
_configure_git_identity(args.repo_root)
|
||||
|
||||
commit_msg = f"Apply reviewer /reject decisions ({args.workload})"
|
||||
_run_git(args.repo_root, ["commit", "-m", commit_msg])
|
||||
_run_git(args.repo_root, ["push", "--force-with-lease", "origin", f"HEAD:{drift_branch}"])
|
||||
|
||||
for path, thread_id in rejections:
|
||||
if thread_id <= 0:
|
||||
continue
|
||||
_post_thread_comment(
|
||||
repo_api=repo_api,
|
||||
pr_id=pr_id,
|
||||
thread_id=thread_id,
|
||||
token=token,
|
||||
content=(
|
||||
"Auto-action: /reject detected. This policy drift was reset to baseline on the rolling drift branch, "
|
||||
"so it is removed from the PR diff.\n\n"
|
||||
"If tenant rollback is required immediately, run restore pipeline as remediation."
|
||||
),
|
||||
)
|
||||
|
||||
print(
|
||||
f"Applied reviewer /reject decisions for {changed} path(s) in PR #{pr_id}; "
|
||||
f"drift branch '{drift_branch}' updated."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"WARNING: Failed to apply reviewer /reject decisions: {exc}", file=sys.stderr)
|
||||
raise
|
||||
Reference in New Issue
Block a user