#!/usr/bin/env python3 """Offline CI failure triage for stdin or a local log file. No network access or external dependencies. Output is heuristic and must be confirmed against the repository and workflow. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import asdict, dataclass from pathlib import Path MAX_INPUT = 1_000_000 SECRET_PATTERNS = [ re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}", re.I), re.compile(r"AKIA[0-9A-Z]{16}"), re.compile(r"(?i)(token|secret|password|api[_-]?key)\s*[:=]\s*[^\s]+"), re.compile(r"(?i)authorization:\s*(?:bearer|basic)\s+[^\s]+"), ] @dataclass(frozen=True) class Rule: name: str pattern: str next_check: str RULES = [ Rule('Dependency or lockfile mismatch', r'(npm ci|yarn|pnpm|pip|poetry|bundle).*(fail|error)|lock.?file|could not resolve|no matching distribution|eresolve', 'Compare package-manager and runtime versions, registry configuration, the committed lockfile, and the exact CI install command.'), Rule('Missing secret or environment variable', r'(secret|token|api[_ -]?key|environment variable).*(missing|undefined|not set|empty)|could not read username', 'Check workflow event and environment scope; fork pull requests and dependency bots often cannot access repository secrets.'), Rule('Permission or authentication failure', r'permission denied|access denied|unauthorized|forbidden|http 40[13]|authentication failed|resource not accessible', 'Inspect job permissions, token scopes, filesystem ownership, protected environments, and read-only event tokens.'), Rule('Test assertion failure', r'assertionerror|expected .* (to|but)|tests? failed|failures?=|panicked at|--- fail:', 'Find the first failing assertion and its inputs; check ordering, timezone, locale, fixtures, and concurrency.'), Rule('Timeout or unavailable service', r'timed? out|deadline exceeded|connection refused|econnrefused|service unavailable|context deadline', 'Check readiness, bound address, health probes, port mapping, retry policy, and dependency startup order.'), Rule('Runtime or toolchain mismatch', r'unsupported.*version|requires (node|python|go|rust)|syntaxerror|unknown option|command not found|not found in path', 'Compare local and CI tool versions, pin the toolchain, and include versions and lockfiles in cache keys.'), Rule('Resource exhaustion', r'no space left|out of memory|oom|killed|cannot allocate memory|too many open files|resource temporarily unavailable', 'Inspect runner resource use, cache growth, artifact size, and concurrency before increasing resources.'), Rule('Container build failure', r'docker build|buildkit|failed to solve|manifest unknown|exec format error|container exited', 'Check build context, architecture, base image, copied paths, and the first failing Dockerfile stage.'), Rule('Lint or static-analysis gate', r'eslint|prettier|ruff|flake8|mypy|golangci|clippy|would reformat', 'Run the exact pinned checker locally and separate autofixable formatting from semantic failures.'), ] def redact(text: str) -> tuple[str, int]: count = 0 for pattern in SECRET_PATTERNS: text, replaced = pattern.subn('[possible secret removed]', text) count += replaced return text, count def analyze(text: str) -> dict: safe, redactions = redact(text) findings = [ {'category': rule.name, 'next_check': rule.next_check} for rule in RULES if re.search(rule.pattern, safe, re.I | re.S) ] if not findings: findings.append({ 'category': 'Unclassified CI failure', 'next_check': 'Start with the earliest error, exact failing command, exit code, runtime versions, and smallest reproducible workflow step.', }) lines = [line for line in safe.splitlines() if line.strip()] return { 'schema': 'offline-ci-triage-v1', 'heuristic_only': True, 'redactions': redactions, 'findings': findings, 'sanitized_excerpt': '\n'.join(lines[-20:])[:5000], 'acceptance_target': 'The named failing check passes on the agreed revision without weakening unrelated checks.', } def markdown(report: dict) -> str: output = ['# CI failure triage', '', '> Heuristic hints only; confirm against the repository and workflow.', ''] for finding in report['findings']: output.extend([f"## {finding['category']}", finding['next_check'], '']) output.extend([ '## Suggested acceptance target', report['acceptance_target'], '', f"Redactions applied: {report['redactions']}", '', '## Sanitized excerpt', '```text', report['sanitized_excerpt'], '```', ]) return '\n'.join(output) + '\n' def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('log', nargs='?', type=Path, help='Log file; omit or use - for stdin') parser.add_argument('--format', choices=['markdown', 'json'], default='markdown') args = parser.parse_args() if args.log and str(args.log) != '-': raw = args.log.read_text(encoding='utf-8', errors='replace') else: raw = sys.stdin.read(MAX_INPUT + 1) if not raw.strip(): raise SystemExit('no log content supplied') if len(raw) > MAX_INPUT: raise SystemExit(f'input exceeds {MAX_INPUT} characters') report = analyze(raw) if args.format == 'json': print(json.dumps(report, ensure_ascii=False, indent=2)) else: print(markdown(report), end='') if __name__ == '__main__': main()