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)