mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
## Summary of the Pull Request Replaces the retired GitHub Models-based automatic issue triage and deduplication flows with the GitHub Agentic Workflow proven in the `niels9001/powertoys-ai-triage-sandbox`. This PR also: - aligns `Needs-Author-Feedback` closure to 7 days for issues and PRs; - removes the automatic GitHub Models issue/PR labeler; - removes the automatic GitHub Models new-issue deduplicator; - removes the Azure Pipelines XAML Styler verification step while retaining the local styling script. This is a draft because production rollout still requires the appropriate privacy and Responsible AI reviews. ## Issue triage rules ### Triggers and refresh behavior - Runs when an issue is opened, edited, or reopened. - Runs when the issue author attaches a `PowerToysReport_*.zip` in a comment. - Maintainers can force regeneration with `/triage refresh`. - Ignores unrelated comments, unchanged issue edits, PR comments, and bot-initiated reopens. - Uses per-issue concurrency so a newer run supersedes an older run. - Maintains one canonical triage comment instead of adding repeated bot comments. ### Comment format - Separates **For the issue author** from **For the PowerToys team**. - Mentions the author once and lists each requested action as a bullet. - Distinguishes blocking **Needed** actions from non-blocking **Recommended** actions. - Shows the product, issue kind, reported PowerToys version, concise summary, diagnostic findings, possible duplicates, and collapsed investigation checks. - Ends with a short disclosure that triage is AI-assisted and maintainers make final decisions. ### Classification and labels - Detects PowerToys bug-template issues deterministically. - Reads the selected product area and adds a matching primary `Product-*` label. - Handles production aliases such as FancyZones Editor and File Explorer preview/thumbnail areas. - Product labeling is additive: existing product and maintainer labels are never removed. - Normalizes the reported PowerToys version and adds a matching version label when one exists. - Applies `Needs-Author-Feedback` only when blocking information or an English translation is required. - Removes `Needs-Author-Feedback` when the issue becomes actionable. ### PowerToys version rule - Compares the reported version with the latest stable PowerToys GitHub release. - Older versions receive a recommended update-and-retest action. - Current versions, newer preview/dev versions, missing versions, and release lookup failures are not flagged as outdated. - Updating is advisory and does not block triage by itself. ### Reproduction rule - Concrete actions plus an observed result are sufficient. - Concise steps can use the separate Actual Behavior section as the observed result. - Passive or intermittent failures are sufficient when the timing/trigger and observed failure are clear. - Vague statements without an actionable scenario remain insufficient. - Clearly non-English steps are not treated as missing; reproduction is reassessed after the author translates the issue. ### Language rule - Classifies author-written prose as English, non-English, or uncertain. - Ignores template headings, code, logs, filenames, URLs, hidden comments, and quoted text. - Clearly non-English issues ask the author to translate the title and description to English. - Short, mixed, code-heavy, or uncertain text is not flagged. ### Diagnostic report rule - A report is **required** for diagnostic-heavy failures: crashes, hangs, startup/load failures, installation/update failures, performance failures, and service/driver/shell-integration failures. - A report is **optional** for clear reproducible UI/visual defects. - A report is **recommended**, but not blocking, for other actionable bugs. - Missing or rejected reports block only when the deterministic requirement is `REQUIRED`. ### Diagnostic report privacy and safety - Accepts only PowerToys report attachment URLs matching the expected pattern. - Enforces archive size, decompressed size, file-count, per-file, path traversal, and encryption limits. - Selects only bounded relevant metadata and product-log evidence. - Redacts email addresses, IP addresses, user paths, URLs, GUIDs, SIDs, identity fields, tokens, secrets, and passwords. - Sends only the sanitized evidence to Copilot. - Never sends the raw ZIP or extracted files to Copilot, logs, artifacts, or repository storage. - Deletes the temporary archive after processing. ### Duplicate rule - Searches only older issues using focused product, title/body, and exact technical-signal queries. - Ranks candidates deterministically before Copilot runs. - Copilot judges only the supplied candidates and returns at most five high-confidence matches. - Similar product area alone is not enough; the underlying request or failure must match. - The model never closes an issue directly. - The workflow submits the strongest match as a native GitHub duplicate-close suggestion. - **When a maintainer accepts the suggestion, GitHub automatically closes the issue as a duplicate and links it to the selected canonical issue.** - Declining the suggestion leaves the issue open. - A defensive safeguard reopens the issue and fails the run if GitHub applies the close without holding it for review. ### AI cost and permission controls - Uses the `small` model alias. - Maximum 5 turns and 10 AI credits per run. - Maximum 300 AI credits per day. - Maximum 5 runs per user per 60-minute window. - Content hashing skips unchanged work before inference. - The agent receives only `contents: read`, `issues: read`, and `copilot-requests: write`. - A separate validated safe-output job receives `issues: write`. ## Seven-day author-feedback lifecycle The existing Microsoft GitHub Policy Service configuration remains responsible for stale closure: - Open issues with `Needs-Author-Feedback` and no activity for 7 days are closed with an explanatory comment. - Open PRs with `Needs-Author-Feedback` and no activity for 7 days are closed with an explanatory comment. - An author comment removes `Needs-Author-Feedback` and returns the issue/PR to team triage. - An author push removes `Needs-Author-Feedback` from a PR. - Manually removing the label immediately makes the issue or PR ineligible for scheduled closure. ## Deprecated automation - Deletes `.github/workflows/automatic-issue-deduplication.yml`. - Deletes `.github/workflows/auto-labeler.yml`. - Automatic PR product labeling from the old Models workflow is intentionally not replaced in this PR; a production PR ownership/path map should be agreed separately. - Keeps the manual batch deduplication workflow unchanged. - Removes the passive XAML Styler verification step from `.pipelines/v2/templates/job-build-project.yml`. - Keeps `.pipelines/applyXamlStyling.ps1` available for local developer use. ## Validation Steps Performed - Compiled `.github/workflows/issue-triage.md` with `gh aw compile`. - Ran 32 focused Python tests for issue parsing, duplicate retrieval, version checks, reproduction rules, language signals, archive validation, report selection, redaction, and output privacy. - Parsed the changed workflow and resource-management YAML. - Verified the required production labels exist. - Tested the workflow against the latest 20 PowerToys issues in the sandbox; all 20 produced one canonical comment. - Verified live variants for outdated versions, intermittent/passive reproduction, non-English issues, rejected and analyzed reports, optional UI reports, and title-only issues. ## PR Checklist - [ ] **Communication:** Discussed with core contributors. - [x] **Tests:** Added/updated and all focused tests pass. - [ ] **Privacy / Responsible AI:** Complete required production reviews before enabling. - [x] **Localization:** No product UI strings are added. - [x] **Dev docs:** Updated repository automation documentation. - [x] **New binaries:** None. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 18a9b8ad-fd7e-4b9d-a06c-5e350bcde9d7 Copilot-Session: fd512b9b-db6f-4004-a65b-aa49404d568d
439 lines
15 KiB
Python
439 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import PurePosixPath
|
|
|
|
|
|
MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024
|
|
MAX_ARCHIVE_ENTRIES = 3000
|
|
MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024
|
|
MAX_ENTRY_BYTES = 8 * 1024 * 1024
|
|
MAX_COMPRESSION_RATIO = 200
|
|
MAX_SOURCE_FILES = 24
|
|
MAX_SIGNAL_COUNT = 10
|
|
MAX_SIGNAL_CHARS = 9000
|
|
MAX_CONTEXT_CHARS = 18000
|
|
|
|
ATTACHMENT_PATTERN = re.compile(
|
|
r"https://github\.com/user-attachments/files/\d+/"
|
|
r"PowerToysReport_[A-Za-z0-9_.-]+\.zip",
|
|
re.IGNORECASE,
|
|
)
|
|
ERROR_PATTERN = re.compile(
|
|
r"\[(?:error|fatal|critical)\]|exception|unable to load|"
|
|
r"could not be found|module could not be found|failed to|"
|
|
r"0x[0-9a-f]{6,}",
|
|
re.IGNORECASE,
|
|
)
|
|
METADATA_FILES = {
|
|
"windows-version.txt",
|
|
"dotnet-installation-info.txt",
|
|
}
|
|
PRODUCT_LOG_HINTS = {
|
|
"fancyzones": ("fancyzones/",),
|
|
"keyboardmanager": ("keyboard manager/", "keyboardmanager/"),
|
|
"colorpicker": ("color picker/", "colorpicker/"),
|
|
"powertoysrun": ("powertoys run/", "powertoysrun/", "launcher/"),
|
|
"awake": ("awake/",),
|
|
"mouseutilities": ("mouse utilities/", "mouseutilities/"),
|
|
}
|
|
|
|
|
|
class AnalysisRejected(Exception):
|
|
pass
|
|
|
|
|
|
class RestrictedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
parsed = urllib.parse.urlparse(newurl)
|
|
if parsed.scheme != "https" or parsed.hostname not in {
|
|
"github.com",
|
|
"objects.githubusercontent.com",
|
|
}:
|
|
raise AnalysisRejected("Attachment redirected to an unapproved host")
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
def find_attachment_url(event):
|
|
issue = event.get("issue") if isinstance(event, dict) else None
|
|
comment = event.get("comment") if isinstance(event, dict) else None
|
|
text = "\n".join(
|
|
value
|
|
for value in [
|
|
issue.get("body") if isinstance(issue, dict) else None,
|
|
comment.get("body") if isinstance(comment, dict) else None,
|
|
]
|
|
if isinstance(value, str)
|
|
)
|
|
matches = ATTACHMENT_PATTERN.findall(text)
|
|
return matches[-1] if matches else None
|
|
|
|
|
|
def parse_issue_area(event):
|
|
issue = event.get("issue") if isinstance(event, dict) else None
|
|
body = issue.get("body") if isinstance(issue, dict) else ""
|
|
if not isinstance(body, str):
|
|
return "Unknown"
|
|
match = re.search(
|
|
r"^###\s+Area\(s\) with issue\?\s*$\s*(.+?)(?=^###|\Z)",
|
|
body,
|
|
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
)
|
|
if not match:
|
|
return "Unknown"
|
|
area = next(
|
|
(line.strip() for line in match.group(1).splitlines() if line.strip()),
|
|
"Unknown",
|
|
)
|
|
return area[:100] or "Unknown"
|
|
|
|
|
|
def validate_attachment_url(url):
|
|
parsed = urllib.parse.urlparse(url)
|
|
if (
|
|
parsed.scheme != "https"
|
|
or parsed.hostname != "github.com"
|
|
or not parsed.path.startswith("/user-attachments/files/")
|
|
or not ATTACHMENT_PATTERN.fullmatch(url)
|
|
):
|
|
raise AnalysisRejected("Attachment URL is not an approved PowerToys report")
|
|
|
|
|
|
def download_attachment(url):
|
|
validate_attachment_url(url)
|
|
opener = urllib.request.build_opener(RestrictedRedirectHandler())
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "microsoft-powertoys-issue-triage"},
|
|
)
|
|
digest = hashlib.sha256()
|
|
temp_file = tempfile.NamedTemporaryFile(prefix="powertoys-report-", suffix=".zip", delete=False)
|
|
try:
|
|
with temp_file, opener.open(request, timeout=30) as response:
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and int(content_length) > MAX_DOWNLOAD_BYTES:
|
|
raise AnalysisRejected("Attachment exceeds the download size limit")
|
|
total = 0
|
|
while True:
|
|
chunk = response.read(64 * 1024)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > MAX_DOWNLOAD_BYTES:
|
|
raise AnalysisRejected("Attachment exceeds the download size limit")
|
|
digest.update(chunk)
|
|
temp_file.write(chunk)
|
|
return temp_file.name, digest.hexdigest()
|
|
except Exception:
|
|
try:
|
|
os.unlink(temp_file.name)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def validate_archive(archive):
|
|
entries = archive.infolist()
|
|
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
|
|
raise AnalysisRejected("Archive has an invalid number of entries")
|
|
|
|
total_size = 0
|
|
for entry in entries:
|
|
path = PurePosixPath(entry.filename)
|
|
unix_mode = entry.external_attr >> 16
|
|
if (
|
|
path.is_absolute()
|
|
or ".." in path.parts
|
|
or "\\" in entry.filename
|
|
or stat.S_ISLNK(unix_mode)
|
|
or entry.flag_bits & 0x1
|
|
):
|
|
raise AnalysisRejected("Archive contains an unsafe entry")
|
|
if entry.file_size > MAX_ENTRY_BYTES:
|
|
raise AnalysisRejected("Archive contains an oversized entry")
|
|
total_size += entry.file_size
|
|
if total_size > MAX_UNCOMPRESSED_BYTES:
|
|
raise AnalysisRejected("Archive exceeds the uncompressed size limit")
|
|
if (
|
|
entry.file_size > 0
|
|
and entry.compress_size > 0
|
|
and entry.file_size / entry.compress_size > MAX_COMPRESSION_RATIO
|
|
):
|
|
raise AnalysisRejected("Archive contains a suspicious compression ratio")
|
|
return entries
|
|
|
|
|
|
def decode_text(raw):
|
|
for encoding in ("utf-8-sig", "utf-16", "cp1252"):
|
|
try:
|
|
return raw.decode(encoding)
|
|
except UnicodeDecodeError:
|
|
continue
|
|
return raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
def redact(text):
|
|
value = text.replace("\x00", "")
|
|
value = re.sub(
|
|
r"(?i)\b[A-Z]:\\Users\\[^\\\s\"']+",
|
|
r"<user-profile>",
|
|
value,
|
|
)
|
|
value = re.sub(r"(?i)/(?:home|Users)/[^/\s\"']+", "/<user>", value)
|
|
value = re.sub(r"\\\\[^\\\s]+\\", r"\\<server>\\", value)
|
|
value = re.sub(
|
|
r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",
|
|
"<email>",
|
|
value,
|
|
)
|
|
value = re.sub(
|
|
r"(?i)\bhttps?://[^\s<>\"]+",
|
|
"<url>",
|
|
value,
|
|
)
|
|
value = re.sub(
|
|
r"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)"
|
|
r"(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b",
|
|
"<ip-address>",
|
|
value,
|
|
)
|
|
value = re.sub(
|
|
r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-"
|
|
r"[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
|
|
"<guid>",
|
|
value,
|
|
)
|
|
value = re.sub(r"\bS-1-5-(?:\d+-){1,14}\d+\b", "<sid>", value)
|
|
value = re.sub(
|
|
r"(?i)\b(token|secret|password|securitykey)\b\s*[:=]\s*[^\s,;]+",
|
|
r"\1=<redacted>",
|
|
value,
|
|
)
|
|
value = re.sub(
|
|
r"(?i)\b(?:machine|computer|user)(?:name)?\b\s*[:=]\s*[^\s,;]+",
|
|
"<identity>=<redacted>",
|
|
value,
|
|
)
|
|
return value
|
|
|
|
|
|
def compact_line(text, limit=700):
|
|
return re.sub(r"\s+", " ", text).strip()[:limit]
|
|
|
|
|
|
def is_relevant_log(filename, area):
|
|
lower = filename.lower()
|
|
if not lower.endswith((".log", ".txt")):
|
|
return False
|
|
if lower.endswith(tuple(METADATA_FILES)):
|
|
return False
|
|
compact_area = re.sub(r"[^a-z0-9]+", "", area.lower())
|
|
compact_path = re.sub(r"[^a-z0-9]+", "", lower)
|
|
configured_hints = PRODUCT_LOG_HINTS.get(compact_area, ())
|
|
area_match = (
|
|
compact_area not in {"", "unknown", "general"}
|
|
and (
|
|
compact_area in compact_path
|
|
or any(hint in lower for hint in configured_hints)
|
|
)
|
|
)
|
|
global_match = any(
|
|
marker in lower
|
|
for marker in ("runnerlogs/", "eventviewer", "event-viewer", "crash")
|
|
)
|
|
return area_match or global_match
|
|
|
|
|
|
def log_relevance(filename, area):
|
|
lower = filename.lower()
|
|
compact_area = re.sub(r"[^a-z0-9]+", "", area.lower())
|
|
compact_path = re.sub(r"[^a-z0-9]+", "", lower)
|
|
configured_hints = PRODUCT_LOG_HINTS.get(compact_area, ())
|
|
if (
|
|
compact_area not in {"", "unknown", "general"}
|
|
and (
|
|
compact_area in compact_path
|
|
or any(hint in lower for hint in configured_hints)
|
|
)
|
|
):
|
|
return 2
|
|
return 1 if any(marker in lower for marker in ("runnerlogs/", "eventviewer", "event-viewer", "crash")) else 0
|
|
|
|
|
|
def read_entry(archive, entry):
|
|
if entry.file_size > MAX_ENTRY_BYTES:
|
|
raise AnalysisRejected("Selected diagnostic file exceeds the size limit")
|
|
return decode_text(archive.read(entry))
|
|
|
|
|
|
def collect_metadata(archive, entries):
|
|
result = []
|
|
for entry in entries:
|
|
name = PurePosixPath(entry.filename).name.lower()
|
|
if entry.is_dir() or name not in METADATA_FILES:
|
|
continue
|
|
lines = read_entry(archive, entry).splitlines()
|
|
if name == "windows-version.txt":
|
|
selected = [
|
|
line
|
|
for line in lines
|
|
if re.search(
|
|
r"product|edition|display.?version|build|architecture",
|
|
line,
|
|
re.IGNORECASE,
|
|
)
|
|
]
|
|
else:
|
|
selected = [
|
|
line
|
|
for line in lines
|
|
if re.search(
|
|
r"host:|architecture:|version:|microsoft\.(?:netcore|windowsdesktop)\.app",
|
|
line,
|
|
re.IGNORECASE,
|
|
)
|
|
]
|
|
text = compact_line(redact("\n".join(selected[:20])), 1000)
|
|
if text:
|
|
result.append((PurePosixPath(entry.filename).name, text))
|
|
return result
|
|
|
|
|
|
def collect_signals(archive, entries, area):
|
|
candidates = [entry for entry in entries if not entry.is_dir() and is_relevant_log(entry.filename, area)]
|
|
candidates.sort(key=lambda entry: (log_relevance(entry.filename, area), entry.date_time), reverse=True)
|
|
signals = []
|
|
seen = set()
|
|
signature_counts = {}
|
|
total_chars = 0
|
|
compact_area = re.sub(r"[^a-z0-9]+", "", area.lower())
|
|
|
|
for entry in candidates[:MAX_SOURCE_FILES]:
|
|
lines = read_entry(archive, entry).splitlines()
|
|
for index, line in enumerate(lines):
|
|
if not ERROR_PATTERN.search(line):
|
|
continue
|
|
excerpt_lines = [line]
|
|
for following in lines[index + 1 : min(index + 4, len(lines))]:
|
|
if re.match(r"^\s*\[(?:\d{2,4}[-/:]|\d{2}:\d{2})", following):
|
|
break
|
|
excerpt_lines.append(following)
|
|
excerpt = " ".join(excerpt_lines)
|
|
excerpt = compact_line(redact(excerpt))
|
|
if (
|
|
log_relevance(entry.filename, area) == 1
|
|
and compact_area not in {"", "unknown", "general"}
|
|
and compact_area not in re.sub(r"[^a-z0-9]+", "", excerpt.lower())
|
|
and not re.search(r"exception|crash|fatal|0x[0-9a-f]{6,}", excerpt, re.IGNORECASE)
|
|
):
|
|
continue
|
|
if not excerpt or excerpt in seen:
|
|
continue
|
|
signature = (
|
|
tuple(sorted(re.findall(r"\b[\w.-]+\.dll\b", excerpt.lower()))),
|
|
tuple(sorted(re.findall(r"\b0x[0-9a-f]{6,}\b", excerpt.lower()))),
|
|
)
|
|
if any(signature) and signature_counts.get(signature, 0) >= 2:
|
|
continue
|
|
seen.add(excerpt)
|
|
if any(signature):
|
|
signature_counts[signature] = signature_counts.get(signature, 0) + 1
|
|
source = PurePosixPath(entry.filename).name
|
|
rendered = f"{source}:{index + 1}: {excerpt}"
|
|
if total_chars + len(rendered) > MAX_SIGNAL_CHARS:
|
|
return signals
|
|
signals.append(rendered)
|
|
total_chars += len(rendered)
|
|
if len(signals) >= MAX_SIGNAL_COUNT:
|
|
return signals
|
|
return signals
|
|
|
|
|
|
def render_context(status, *, area="Unknown", sha256="", metadata=None, signals=None, reason=""):
|
|
metadata = metadata or []
|
|
signals = signals or []
|
|
lines = [
|
|
"# Sanitized PowerToys bug report context",
|
|
"",
|
|
f"Status: {status}",
|
|
f"Detected issue area: {area}",
|
|
]
|
|
if sha256:
|
|
lines.append(f"Attachment SHA-256: {sha256}")
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"Raw archive contents were not provided to the model. Only the bounded, redacted diagnostics below are available.",
|
|
]
|
|
)
|
|
if reason:
|
|
lines.extend(["", f"Safe processing result: {compact_line(reason, 300)}"])
|
|
if metadata:
|
|
lines.extend(["", "## Environment metadata"])
|
|
lines.extend(f"- {name}: {value}" for name, value in metadata)
|
|
if signals:
|
|
lines.extend(["", "## Diagnostic signals"])
|
|
lines.extend(f"- {signal}" for signal in signals)
|
|
if status == "ANALYZED" and not signals:
|
|
lines.extend(["", "No matching error or crash signatures were found in the bounded diagnostic subset."])
|
|
return "\n".join(lines)[:MAX_CONTEXT_CHARS] + "\n"
|
|
|
|
|
|
def analyze_event(event):
|
|
url = find_attachment_url(event)
|
|
area = parse_issue_area(event)
|
|
if not url:
|
|
return render_context("NOT_FOUND", area=area)
|
|
|
|
archive_path = None
|
|
try:
|
|
archive_path, sha256 = download_attachment(url)
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
entries = validate_archive(archive)
|
|
metadata = collect_metadata(archive, entries)
|
|
signals = collect_signals(archive, entries, area)
|
|
return render_context(
|
|
"ANALYZED",
|
|
area=area,
|
|
sha256=sha256,
|
|
metadata=metadata,
|
|
signals=signals,
|
|
)
|
|
except (AnalysisRejected, zipfile.BadZipFile, OSError, ValueError) as error:
|
|
return render_context("REJECTED", area=area, reason=str(error))
|
|
finally:
|
|
if archive_path:
|
|
try:
|
|
os.unlink(archive_path)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 3:
|
|
print("Usage: bug-report-analyzer.py EVENT_JSON OUTPUT_MARKDOWN", file=sys.stderr)
|
|
return 2
|
|
event_path, output_path = sys.argv[1:3]
|
|
with open(event_path, "r", encoding="utf-8") as event_file:
|
|
event = json.load(event_file)
|
|
context = analyze_event(event)
|
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
|
with open(output_path, "w", encoding="utf-8", newline="\n") as output_file:
|
|
output_file.write(context)
|
|
status_match = re.search(r"^Status: (\w+)$", context, re.MULTILINE)
|
|
print(f"Bug report preprocessing status: {status_match.group(1) if status_match else 'UNKNOWN'}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|