#!/usr/bin/env python3 """Prepare a reviewable, sanitized CI failure case without network access. The tool redacts a CI log, selects bounded context around likely failure lines, and writes a Markdown draft. It never sends data or modifies the source log. Pattern-based redaction is not a guarantee; manually review the output. """ from __future__ import annotations import argparse import re from pathlib import Path from redact_ci_log import MAX_BYTES, read_input, redact FAILURE = re.compile( r"(?i)(?:\berror\b|\bfatal\b|\bfailed?\b|exception|traceback|" r"assertionerror|segmentation fault|process completed with exit code [1-9])" ) def one_line(value: str, limit: int = 300) -> str: cleaned = " ".join(value.replace("\x00", "").split()) return cleaned[:limit] def select_context(text: str, before: int, after: int, max_lines: int) -> tuple[list[str], int]: lines = text.splitlines() matches = [index for index, line in enumerate(lines) if FAILURE.search(line)] selected: set[int] = set() for index in matches: selected.update(range(max(0, index - before), min(len(lines), index + after + 1))) if len(selected) >= max_lines: break ordered = sorted(selected)[:max_lines] return [lines[index] for index in ordered], len(matches) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("log", help="CI log path, or - for stdin") parser.add_argument("--repository", required=True, help="Public repository URL or non-sensitive identifier") parser.add_argument("--check", required=True, help="Failing CI check name") parser.add_argument("--output", required=True, help="New Markdown output path") parser.add_argument("--before", type=int, default=3, help="Context lines before each failure") parser.add_argument("--after", type=int, default=5, help="Context lines after each failure") parser.add_argument("--max-lines", type=int, default=120, help="Maximum selected log lines") parser.add_argument("--force", action="store_true", help="Replace an existing output file") args = parser.parse_args() if not 0 <= args.before <= 20 or not 0 <= args.after <= 20: raise SystemExit("--before and --after must be between 0 and 20") if not 1 <= args.max_lines <= 500: raise SystemExit("--max-lines must be between 1 and 500") repository = one_line(args.repository) check = one_line(args.check) if not repository or not check: raise SystemExit("repository and check must not be empty") raw, source = read_input(args.log) sanitized, counts = redact(raw) excerpt, match_count = select_context(sanitized, args.before, args.after, args.max_lines) if not excerpt: excerpt = sanitized.splitlines()[-min(args.max_lines, 40):] destination = Path(args.output).resolve() if source is not None and destination == source: raise SystemExit("output must not overwrite the source log") if destination.exists() and not args.force: raise SystemExit("output exists; use --force to replace it") redaction_summary = ", ".join( f"{name}={count}" for name, count in sorted(counts.items()) ) or "none detected" body = "\n".join(excerpt) document = f"""# Sanitized CI failure case Repository: {repository} Failing check: {check} Source size: {len(raw.encode('utf-8'))} bytes (limit {MAX_BYTES}) Failure-like lines detected: {match_count} Redactions applied: {redaction_summary} ## Selected log context ```text {body} ``` ## Manual review checklist - [ ] Confirm no credentials, cookies, private keys, customer data, or private URLs remain. - [ ] Remove unrelated source code and personal information. - [ ] Confirm the repository identifier and check name are safe to disclose. - [ ] Add reproduction steps separately only if they contain no secrets. This local draft was generated without network access and was not submitted. Pattern-based redaction cannot guarantee anonymity or removal of every secret. """ destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(document, encoding="utf-8") destination.chmod(0o600) print(destination) if __name__ == "__main__": main()