Sync from dev @ 497baf0
Source: main (497baf0) Excluded: live tenant exports, generated artifacts, and dev-only tooling.
This commit is contained in:
@@ -54,6 +54,14 @@ TICKET_BLOCK_END = "<!-- AUTO-CHANGE-TICKETS:END -->"
|
||||
AUTO_TICKET_THREAD_PREFIX = "AUTO-CHANGE-TICKET:"
|
||||
AUTO_AI_REVIEW_THREAD_PREFIX = "AUTO-AI-REVIEW:"
|
||||
COMPACT_AI_THREAD_NOTE = "_Full AI reviewer narrative is posted in a dedicated PR thread due PR description limits._"
|
||||
AUTO_DETERMINISTIC_THREAD_PREFIX = "AUTO-DETERMINISTIC-SUMMARY:"
|
||||
COMPACT_DETERMINISTIC_THREAD_NOTE = (
|
||||
"_Full deterministic summary (including Top Risk Items) is posted in a dedicated PR thread "
|
||||
"due to Azure DevOps description size limits._"
|
||||
)
|
||||
ADO_PR_DESCRIPTION_MAX_LEN = 4000
|
||||
AUTO_REVIEWER_GUIDE_THREAD_PREFIX = "AUTO-REVIEWER-GUIDE:"
|
||||
COMPACT_REVIEWER_GUIDE_NOTE = "> 📋 Full **reviewer guide** is posted in a dedicated PR thread."
|
||||
|
||||
THREAD_STATUS_ACTIVE = 1
|
||||
THREAD_STATUS_FIXED = 2
|
||||
@@ -2035,6 +2043,29 @@ def _compact_deterministic_summary(deterministic_summary: str) -> str:
|
||||
return deterministic_summary[:idx].strip()
|
||||
|
||||
|
||||
def _compact_reviewer_guide(description: str) -> str:
|
||||
"""Replace the legacy long reviewer guide with a compact reference."""
|
||||
description = description or ""
|
||||
marker = "## Reviewer Quick Actions"
|
||||
idx = description.find(marker)
|
||||
if idx == -1:
|
||||
return description
|
||||
prefix = description[:idx].rstrip()
|
||||
if not prefix:
|
||||
return COMPACT_REVIEWER_GUIDE_NOTE + "\n"
|
||||
return prefix + "\n\n" + COMPACT_REVIEWER_GUIDE_NOTE + "\n"
|
||||
|
||||
|
||||
def _append_reviewer_guide_note(description: str) -> str:
|
||||
"""Append the compact reviewer guide note if not already present."""
|
||||
description = description or ""
|
||||
if COMPACT_REVIEWER_GUIDE_NOTE in description:
|
||||
return description
|
||||
if description.endswith("\n"):
|
||||
return description + COMPACT_REVIEWER_GUIDE_NOTE + "\n"
|
||||
return description + "\n\n" + COMPACT_REVIEWER_GUIDE_NOTE + "\n"
|
||||
|
||||
|
||||
def _remove_marked_block(description: str, start_marker: str, end_marker: str) -> str:
|
||||
description = description or ""
|
||||
pattern = re.compile(
|
||||
@@ -2273,6 +2304,185 @@ def _sync_full_ai_review_thread(
|
||||
return True
|
||||
|
||||
|
||||
def _deterministic_thread_marker(workload: str) -> str:
|
||||
return f"Automation marker: {AUTO_DETERMINISTIC_THREAD_PREFIX}{workload.strip().lower()}"
|
||||
|
||||
|
||||
def _build_full_deterministic_thread_content(workload: str, deterministic_summary: str) -> str:
|
||||
marker = _deterministic_thread_marker(workload)
|
||||
return (
|
||||
"Automated review summary (full)\n\n"
|
||||
"PR description uses a compact review summary because of Azure DevOps description size limits.\n\n"
|
||||
f"{deterministic_summary}\n\n"
|
||||
f"{marker}"
|
||||
).strip()
|
||||
|
||||
|
||||
def _create_deterministic_thread(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
token: str,
|
||||
workload: str,
|
||||
deterministic_summary: str,
|
||||
) -> None:
|
||||
content = _build_full_deterministic_thread_content(workload, deterministic_summary)
|
||||
_request_json(
|
||||
f"{repo_api}/pullrequests/{pr_id}/threads?api-version=7.1",
|
||||
token=token,
|
||||
method="POST",
|
||||
body={
|
||||
"comments": [
|
||||
{
|
||||
"parentCommentId": 0,
|
||||
"content": content,
|
||||
"commentType": 1,
|
||||
}
|
||||
],
|
||||
"status": THREAD_STATUS_ACTIVE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sync_deterministic_thread(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
token: str,
|
||||
workload: str,
|
||||
deterministic_summary: str,
|
||||
) -> bool:
|
||||
marker = _deterministic_thread_marker(workload)
|
||||
desired_content = _build_full_deterministic_thread_content(workload, deterministic_summary)
|
||||
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 []
|
||||
thread = _find_marked_thread(threads, marker)
|
||||
if thread is None:
|
||||
_create_deterministic_thread(repo_api, pr_id, token, workload, deterministic_summary)
|
||||
return True
|
||||
|
||||
comments = thread.get("comments", []) if isinstance(thread.get("comments"), list) else []
|
||||
if _thread_has_matching_comment(comments, desired_content):
|
||||
return False
|
||||
|
||||
thread_id = _thread_id(thread)
|
||||
if thread_id <= 0:
|
||||
_create_deterministic_thread(repo_api, pr_id, token, workload, deterministic_summary)
|
||||
return True
|
||||
|
||||
if _is_thread_resolved(thread):
|
||||
_set_thread_status(repo_api, pr_id, thread_id, token, THREAD_STATUS_ACTIVE)
|
||||
_add_thread_comment(repo_api, pr_id, thread_id, token, desired_content)
|
||||
return True
|
||||
|
||||
|
||||
def _close_deterministic_thread(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
token: str,
|
||||
workload: str,
|
||||
) -> bool:
|
||||
marker = _deterministic_thread_marker(workload)
|
||||
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 []
|
||||
thread = _find_marked_thread(threads, marker)
|
||||
if thread is None:
|
||||
return False
|
||||
thread_id = _thread_id(thread)
|
||||
if thread_id <= 0:
|
||||
return False
|
||||
if _is_thread_resolved(thread):
|
||||
return False
|
||||
_set_thread_status(repo_api, pr_id, thread_id, token, THREAD_STATUS_CLOSED)
|
||||
return True
|
||||
|
||||
|
||||
def _reviewer_guide_thread_marker(workload: str) -> str:
|
||||
return f"Automation marker: {AUTO_REVIEWER_GUIDE_THREAD_PREFIX}{workload.strip().lower()}"
|
||||
|
||||
|
||||
def _build_full_reviewer_guide_thread_content(workload: str) -> str:
|
||||
marker = _reviewer_guide_thread_marker(workload)
|
||||
return (
|
||||
"## Reviewer Quick Actions\n\n"
|
||||
"### 1) Accept all changes\n"
|
||||
"- Merge PR to accept drift into baseline.\n\n"
|
||||
"### 2) Reject whole PR and revert\n"
|
||||
"- Set reviewer vote to **Reject**.\n"
|
||||
"- Abandon PR.\n"
|
||||
"- Auto-remediation queues restore (if `AUTO_REMEDIATE_ON_PR_REJECTION=true`).\n\n"
|
||||
"### 3) Reject only selected policy changes\n"
|
||||
"- In each `Change Needed` policy thread, comment `/reject` for changes you do not want.\n"
|
||||
"- Optional: use `/accept` for changes you want to keep.\n"
|
||||
"- Wait for review-sync pipeline (about 5 minutes) to update PR diff.\n"
|
||||
"- Merge remaining accepted changes.\n"
|
||||
"- Post-merge auto-remediation queues restore to reconcile tenant to merged baseline "
|
||||
"(if `AUTO_REMEDIATE_AFTER_MERGE=true`).\n\n"
|
||||
f"{marker}"
|
||||
).strip()
|
||||
|
||||
|
||||
def _create_reviewer_guide_thread(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
token: str,
|
||||
workload: str,
|
||||
) -> None:
|
||||
content = _build_full_reviewer_guide_thread_content(workload)
|
||||
_request_json(
|
||||
f"{repo_api}/pullrequests/{pr_id}/threads?api-version=7.1",
|
||||
token=token,
|
||||
method="POST",
|
||||
body={
|
||||
"comments": [
|
||||
{
|
||||
"parentCommentId": 0,
|
||||
"content": content,
|
||||
"commentType": 1,
|
||||
}
|
||||
],
|
||||
"status": THREAD_STATUS_ACTIVE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _sync_reviewer_guide_thread(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
token: str,
|
||||
workload: str,
|
||||
) -> bool:
|
||||
marker = _reviewer_guide_thread_marker(workload)
|
||||
desired_content = _build_full_reviewer_guide_thread_content(workload)
|
||||
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 []
|
||||
thread = _find_marked_thread(threads, marker)
|
||||
if thread is None:
|
||||
_create_reviewer_guide_thread(repo_api, pr_id, token, workload)
|
||||
return True
|
||||
|
||||
comments = thread.get("comments", []) if isinstance(thread.get("comments"), list) else []
|
||||
if _thread_has_matching_comment(comments, desired_content):
|
||||
return False
|
||||
|
||||
thread_id = _thread_id(thread)
|
||||
if thread_id <= 0:
|
||||
_create_reviewer_guide_thread(repo_api, pr_id, token, workload)
|
||||
return True
|
||||
|
||||
if _is_thread_resolved(thread):
|
||||
_set_thread_status(repo_api, pr_id, thread_id, token, THREAD_STATUS_ACTIVE)
|
||||
_add_thread_comment(repo_api, pr_id, thread_id, token, desired_content)
|
||||
return True
|
||||
|
||||
|
||||
def _set_thread_status(
|
||||
repo_api: str,
|
||||
pr_id: int,
|
||||
@@ -2530,12 +2740,16 @@ def main() -> int:
|
||||
)
|
||||
|
||||
full_pr = _request_json(f"{repo_api}/pullrequests/{pr_id}?api-version=7.1", token=token)
|
||||
current_description = full_pr.get("description", "")
|
||||
current_description = full_pr.get("description") or ""
|
||||
pr_is_draft = bool(full_pr.get("isDraft"))
|
||||
existing_fingerprint = _existing_change_fingerprint(current_description)
|
||||
existing_summary_version = _existing_summary_version(current_description)
|
||||
current_auto_body = _auto_block_body(current_description)
|
||||
deterministic_already_present = deterministic in current_auto_body if current_auto_body else False
|
||||
compact_deterministic = _compact_deterministic_summary(deterministic)
|
||||
deterministic_already_present = (
|
||||
(deterministic in current_auto_body)
|
||||
or (compact_deterministic in current_auto_body)
|
||||
) if current_auto_body else False
|
||||
ai_fallback_in_current_block = _auto_block_contains_ai_fallback(current_auto_body)
|
||||
refresh_on_fallback = _env_bool("PR_AI_FORCE_REFRESH_ON_FALLBACK", default=True)
|
||||
if existing_fingerprint and existing_fingerprint == changes_fingerprint:
|
||||
@@ -2549,7 +2763,7 @@ def main() -> int:
|
||||
repo_api=repo_api,
|
||||
token=token,
|
||||
pr_id=int(pr_id),
|
||||
title=full_pr.get("title", pr.get("title", f"{args.workload} drift review (rolling)")),
|
||||
title=full_pr.get("title") or pr.get("title") or f"{args.workload} drift review (rolling)",
|
||||
description=current_description,
|
||||
is_draft=pr_is_draft,
|
||||
)
|
||||
@@ -2625,29 +2839,44 @@ def main() -> int:
|
||||
updated_description = _upsert_auto_block(current_description, auto_block)
|
||||
# Cleanup legacy description-based ticket checklist if present.
|
||||
updated_description = _remove_marked_block(updated_description, TICKET_BLOCK_START, TICKET_BLOCK_END)
|
||||
# Strip legacy long reviewer guide and ensure compact note is present.
|
||||
updated_description = _compact_reviewer_guide(updated_description)
|
||||
updated_description = _append_reviewer_guide_note(updated_description)
|
||||
|
||||
patch_url = f"{repo_api}/pullrequests/{pr_id}?api-version=7.1"
|
||||
patch_title = full_pr.get("title", pr.get("title", f"{args.workload} drift review (rolling)"))
|
||||
patch_title = full_pr.get("title") or pr.get("title") or f"{args.workload} drift review (rolling)"
|
||||
summary_updated = False
|
||||
final_description = current_description
|
||||
description_compacted = False
|
||||
print(
|
||||
f"DEBUG summary: pr_id={pr_id} workload={args.workload} "
|
||||
f"status={full_pr.get('status')} isDraft={full_pr.get('isDraft')} "
|
||||
f"mergeStatus={full_pr.get('mergeStatus')} title_len={len(patch_title)} "
|
||||
f"current_desc_len={len(current_description or '')} updated_desc_len={len(updated_description or '')}"
|
||||
)
|
||||
# Proactively compact if we are near the Azure DevOps PR description limit.
|
||||
if len(updated_description) > (ADO_PR_DESCRIPTION_MAX_LEN - 100):
|
||||
description_compacted = True
|
||||
|
||||
if updated_description != current_description:
|
||||
try:
|
||||
_request_json(
|
||||
patch_url,
|
||||
token=token,
|
||||
method="PATCH",
|
||||
body={
|
||||
"title": patch_title,
|
||||
"description": updated_description,
|
||||
},
|
||||
)
|
||||
summary_updated = True
|
||||
final_description = updated_description
|
||||
except RuntimeError as exc:
|
||||
if not _is_description_limit_error(exc):
|
||||
raise
|
||||
description_compacted = True
|
||||
if not description_compacted:
|
||||
try:
|
||||
_request_json(
|
||||
patch_url,
|
||||
token=token,
|
||||
method="PATCH",
|
||||
body={
|
||||
"title": patch_title,
|
||||
"description": updated_description,
|
||||
},
|
||||
)
|
||||
summary_updated = True
|
||||
final_description = updated_description
|
||||
except RuntimeError as exc:
|
||||
if not _is_description_limit_error(exc):
|
||||
raise
|
||||
description_compacted = True
|
||||
if description_compacted:
|
||||
compact_ai_block = ""
|
||||
if ai_summary:
|
||||
compact_ai_block = "\n### AI Reviewer Narrative\n" + COMPACT_AI_THREAD_NOTE
|
||||
@@ -2660,6 +2889,8 @@ def main() -> int:
|
||||
"",
|
||||
f"- **Summary Version:** `{AUTO_SUMMARY_VERSION}`",
|
||||
_compact_deterministic_summary(deterministic),
|
||||
"",
|
||||
COMPACT_DETERMINISTIC_THREAD_NOTE,
|
||||
compact_ai_block,
|
||||
AUTO_BLOCK_END,
|
||||
]
|
||||
@@ -2670,10 +2901,11 @@ def main() -> int:
|
||||
)
|
||||
if compact_description == updated_description:
|
||||
raise
|
||||
print(
|
||||
"WARNING: Full PR summary update failed; retrying with compact summary block. "
|
||||
f"Reason: {exc}"
|
||||
)
|
||||
if not summary_updated:
|
||||
print(
|
||||
"INFO: Full PR summary exceeds Azure DevOps description limit; "
|
||||
"using compact summary in description and posting full details to a PR thread."
|
||||
)
|
||||
try:
|
||||
_request_json(
|
||||
patch_url,
|
||||
@@ -2697,6 +2929,7 @@ def main() -> int:
|
||||
f"- **Summary Version:** `{AUTO_SUMMARY_VERSION}`",
|
||||
_compact_deterministic_summary(deterministic),
|
||||
"",
|
||||
COMPACT_DETERMINISTIC_THREAD_NOTE,
|
||||
COMPACT_AI_THREAD_NOTE,
|
||||
AUTO_BLOCK_END,
|
||||
]
|
||||
@@ -2720,6 +2953,34 @@ def main() -> int:
|
||||
else:
|
||||
final_description = updated_description
|
||||
|
||||
if description_compacted:
|
||||
try:
|
||||
thread_updated = _sync_deterministic_thread(
|
||||
repo_api=repo_api,
|
||||
pr_id=int(pr_id),
|
||||
token=token,
|
||||
workload=args.workload,
|
||||
deterministic_summary=deterministic,
|
||||
)
|
||||
if thread_updated:
|
||||
print(f"Updated full deterministic summary thread for PR #{pr_id} ({args.workload}).")
|
||||
else:
|
||||
print(f"Full deterministic summary thread already up to date for PR #{pr_id} ({args.workload}).")
|
||||
except Exception as exc:
|
||||
print(f"WARNING: Failed to sync full deterministic summary thread for PR #{pr_id}: {exc}")
|
||||
else:
|
||||
try:
|
||||
closed = _close_deterministic_thread(
|
||||
repo_api=repo_api,
|
||||
pr_id=int(pr_id),
|
||||
token=token,
|
||||
workload=args.workload,
|
||||
)
|
||||
if closed:
|
||||
print(f"Closed full deterministic summary thread for PR #{pr_id} ({args.workload}) because description now fits.")
|
||||
except Exception as exc:
|
||||
print(f"WARNING: Failed to close deterministic summary thread for PR #{pr_id}: {exc}")
|
||||
|
||||
if summary_updated:
|
||||
print(f"Updated automated review summary for PR #{pr_id} ({args.workload}).")
|
||||
else:
|
||||
@@ -2739,6 +3000,19 @@ def main() -> int:
|
||||
print(f"Full AI reviewer narrative thread already up to date for PR #{pr_id} ({args.workload}).")
|
||||
except Exception as exc:
|
||||
print(f"WARNING: Failed to sync full AI reviewer narrative thread for PR #{pr_id}: {exc}")
|
||||
try:
|
||||
guide_updated = _sync_reviewer_guide_thread(
|
||||
repo_api=repo_api,
|
||||
pr_id=int(pr_id),
|
||||
token=token,
|
||||
workload=args.workload,
|
||||
)
|
||||
if guide_updated:
|
||||
print(f"Updated reviewer guide thread for PR #{pr_id} ({args.workload}).")
|
||||
else:
|
||||
print(f"Reviewer guide thread already up to date for PR #{pr_id} ({args.workload}).")
|
||||
except Exception as exc:
|
||||
print(f"WARNING: Failed to sync reviewer guide thread for PR #{pr_id}: {exc}")
|
||||
if _publish_draft_pr(
|
||||
repo_api=repo_api,
|
||||
token=token,
|
||||
|
||||
Reference in New Issue
Block a user