import hashlib import html import re from typing import Iterable, List, Optional, Sequence, Set, Tuple from model import Action, Issue, Occurrence KEY_RE = re.compile(r"^$", re.MULTILINE) OCCURRENCE_RE = re.compile(r"^$", re.MULTILINE) PAYLOAD_LIMIT = 60000 NEW_PREFIX = "Automated flaky test report." UPDATE_PREFIX = "Automated flaky test report update." RECURRENCE_PREFIX = "Automated reopen: flaky detected after this issue was closed." CONTINUATION_PREFIX = "Automated flaky test report continuation." NEW_NOTE = "Note: This issue is created for a specific branch; updates are posted as comments." GENERATED_PREFIXES = (NEW_PREFIX, UPDATE_PREFIX, RECURRENCE_PREFIX, CONTINUATION_PREFIX) class CandidateConflict(Exception): pass def issue_key(repository: str, branch: str, classname: str, testcase_name: str) -> str: return hashlib.sha256("\0".join((repository, branch, classname, testcase_name)).encode("utf-8")).hexdigest() def _generated(content: str) -> bool: # Anti-forgery gate: only reconciler-emitted documents are trusted for # markers. The leading prefix line identifies them even after maintainer # edits; forgery requires visible bot-imitating text, since escaped user # values stay on one line and cannot form an anchored marker line. return content.startswith(GENERATED_PREFIXES) def key_markers(content: str) -> Set[str]: if not _generated(content): return set() return set(KEY_RE.findall(content)) def occurrence_markers(content: str) -> Set[str]: if not _generated(content): return set() return set(OCCURRENCE_RE.findall(content)) def _bounded(value: str, limit: int) -> str: escaped: List[str] = [] for character in value: code = ord(character) if code < 32 or 127 <= code <= 159: escaped.append("\\u{%x}" % code) elif character == "@": escaped.append("@\u200b") else: escaped.append(html.escape(character, quote=False)) result = "".join(escaped) encoded = result.encode("utf-8") if len(encoded) <= limit: return result suffix = "… [sha256:%s]" % hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] budget = limit - len(suffix.encode("utf-8")) shortened = encoded[:budget] while True: try: return shortened.decode("utf-8") + suffix except UnicodeDecodeError: shortened = shortened[:-1] def _code(value: str, limit: int) -> str: value = _bounded(value, limit) runs: List[str] = re.findall(r"`+", value) longest = max([len(run) for run in runs] or [0]) delimiter = "`" * (longest + 1) return "%s%s%s" % (delimiter, value, delimiter) def _legacy_matches(issue: Issue, repository: str, branch: str, classname: str, testcase_name: str) -> bool: expected = { "Repo": _code(repository, 256), "Branch": _code(branch, 256), "Package": _code(classname, 512), "Case": _code(testcase_name, 512), } return all(re.search(r"^- %s: %s$" % (field, re.escape(value)), issue.body, re.MULTILINE) for field, value in expected.items()) def find_candidate(repository: str, branch: str, classname: str, testcase_name: str, candidates: Sequence[Issue]) -> Optional[Issue]: matches = matching_candidates(repository, branch, classname, testcase_name, candidates) if len(matches) > 1: raise CandidateConflict("multiple matching issues") return matches[0] if matches else None def matching_candidates(repository: str, branch: str, classname: str, testcase_name: str, candidates: Sequence[Issue]) -> List[Issue]: key = issue_key(repository, branch, classname, testcase_name) marked = [issue for issue in candidates if key in key_markers(issue.body)] legacy = [issue for issue in candidates if not key_markers(issue.body) and _legacy_matches(issue, repository, branch, classname, testcase_name)] return marked + legacy def _detection(occurrences: Iterable[Occurrence]) -> str: modes: Set[str] = set() for occurrence in occurrences: modes.update(occurrence.modes) if modes == {"nextest retry", "workflow rerun"}: return "both" return ", ".join(sorted(modes)) def _evidence(occurrence: Occurrence) -> str: return "- %s, run %s, attempt %s, %s\n\n" % ( _code(occurrence.workflow_name, 256), occurrence.run_id, occurrence.final_attempt, _code(occurrence.run_url, 2048), occurrence.fingerprint, ) def _chunks(header: str, occurrences: Sequence[Occurrence], footer: str = "") -> Tuple[str, ...]: records = [_evidence(item) for item in sorted(occurrences, key=lambda item: item.fingerprint)] chunks: List[str] = [] current = header for record in records: addition = "\n" + record current_footer = footer if not chunks else "" if len((current + addition + current_footer).encode("utf-8")) > PAYLOAD_LIMIT and current != header: chunks.append(current + current_footer) current = CONTINUATION_PREFIX + "\n\nEvidence:" current += addition chunks.append(current + (footer if not chunks else "")) return tuple(chunks) def _header(prefix: str, branch: str, occurrences: Sequence[Occurrence], closed_at: Optional[str] = None) -> str: latest = max(occurrences, key=lambda item: item.occurred_at) earliest = min(item.occurred_at for item in occurrences) lines = [prefix, "", "- Repo: %s" % _code(latest.repository, 256), "- Branch: %s" % _code(branch, 256), "- Package: %s" % _code(latest.classname, 512), "- Case: %s" % _code(latest.testcase_name, 512)] if closed_at is not None: lines.append("- Issue closed_at: %s" % _code(closed_at, 256)) lines.extend(("- Flaky Count: %d" % len(occurrences), "- Detection: %s" % _detection(occurrences), "- Latest Build: %s" % _code(latest.run_url, 2048), "", "Window: %s → %s" % (_code(earliest, 256), _code(latest.occurred_at, 256)), "", "Evidence:")) return "\n".join(lines) def render_new_issue(branch: str, occurrences: Sequence[Occurrence]) -> Tuple[str, Tuple[str, ...]]: latest = occurrences[-1] key = issue_key(latest.repository, branch, latest.classname, latest.testcase_name) raw_title = "Flaky test: %s in %s" % (_bounded(latest.testcase_name, 512), _bounded(latest.classname, 512)) title = raw_title if len(raw_title) <= 256 else raw_title[:238] + "… [%s]" % key[:12] header = _header(NEW_PREFIX, branch, occurrences) footer = "\n\n%s\n\n" % (NEW_NOTE, key) return title, _chunks(header, occurrences, footer) def plan_issue(branch: str, occurrences: Sequence[Occurrence], issues: Sequence[Issue]) -> Action: latest = occurrences[-1] key = issue_key(latest.repository, branch, latest.classname, latest.testcase_name) try: candidate = find_candidate(latest.repository, branch, latest.classname, latest.testcase_name, issues) except CandidateConflict: return Action("conflict", key, None, "", (), None) if candidate is None: title, chunks = render_new_issue(branch, occurrences) return Action("create", key, None, title, chunks, None) body_markers = occurrence_markers(candidate.body) existing = set(body_markers) for comment in candidate.comments: existing.update(occurrence_markers(comment)) fresh = [item for item in occurrences if item.fingerprint not in existing] if candidate.state == "closed" and candidate.closed_at: fresh = [item for item in fresh if item.occurred_at > candidate.closed_at] if not fresh: return Action("noop", key, candidate.number, "", (), candidate.closed_at) if candidate.state == "closed": header = _header(RECURRENCE_PREFIX, branch, fresh, candidate.closed_at) return Action("reopen", key, candidate.number, "", _chunks(header, fresh), candidate.closed_at) current_fingerprints = {item.fingerprint for item in occurrences} if body_markers.intersection(current_fingerprints) and candidate.body.startswith(NEW_PREFIX): # The body of an earlier partial create is present. Re-render its # continuation chunks and replay those containing only fresh records. _, original_chunks = render_new_issue(branch, occurrences) fresh_fingerprints = {item.fingerprint for item in fresh} continuations = tuple(chunk for chunk in original_chunks[1:] if occurrence_markers(chunk) <= fresh_fingerprints) covered: Set[str] = set() for chunk in continuations: covered.update(occurrence_markers(chunk)) if covered >= fresh_fingerprints: return Action("comment", key, candidate.number, "", continuations, None) # A new occurrence inserts at an arbitrary fingerprint-sorted position # and shifts chunk boundaries, so a re-rendered continuation can mix # posted and fresh records and fail the fresh-only filter. Fall through # and post every fresh record as an ordinary update batch instead of # deferring the mixed-chunk records to a later scan. header = _header(UPDATE_PREFIX, branch, fresh) return Action("comment", key, candidate.number, "", _chunks(header, fresh), None)