Files
PowerToys/.github/scripts/issue-triage/bug-report-analyzer.py
Niels Laute 57b01a1c4e Add issue triage actions (#49828)
## 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
2026-08-14 14:57:02 +08:00

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())