Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions dep_checker/reconcile_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
listed by the per-stream LABEL, which is index-independent and immediately consistent, so
near-simultaneous runs cannot create duplicates.

Closed issues are never touched: a reappearing vulnerability always gets a fresh issue.
Closed issues are never re-opened. A vulnerability whose key matches a closed
issue carrying a "won't fix" label (any case, ``_`` or space) is suppressed and
not re-created. Other reappearing vulnerabilities get a fresh issue.
"""

from __future__ import annotations
Expand All @@ -36,6 +38,15 @@
# Severity labels are mutually exclusive, so they are reset (not just added) on update.
SEVERITY_LABELS = {"CRITICAL", "HIGH", "MODERATE", "MEDIUM", "LOW"}

# Labels marking a closed issue as "won't fix"; matched case-insensitively, with
# ``_`` and space treated as equivalent. A vuln whose key matches a closed issue
# carrying any of these labels is not re-opened on subsequent scans.
WONT_FIX_LABEL = "WONT FIX" # normalized form (uppercase, underscore -> space)


def _is_wont_fix_label(label_name: str) -> bool:
return label_name.upper().replace("_", " ").strip() == WONT_FIX_LABEL


def eprint(*args: Any) -> None:
print(*args, file=sys.stderr)
Expand Down Expand Up @@ -205,6 +216,29 @@ def list_open_issues(self, stream: str, limit: int = 1000) -> List[Dict[str, Any
eprint(f"WARNING: open-issue list hit the limit of {limit}; results may be truncated")
return issues

def list_closed_issues(self, stream: str, limit: int = 1000) -> List[Dict[str, Any]]:
"""Closed issues for the stream, used to detect won't-fix suppressions.

Fails closed: if the result hits ``limit`` it is likely truncated, which
would make the suppression set incomplete and allow a won't-fix vuln to be
re-opened. Abort rather than proceed with partial data.
"""
out = self._run([
"issue", "list",
"--state", "closed",
"--label", stream,
"--limit", str(limit),
"--json", "number,title,body,labels",
])
issues = json.loads(out) if out.strip() else []
if len(issues) >= limit:
raise RuntimeError(
f"closed-issue list for stream '{stream}' hit the limit of {limit}; "
f"results may be truncated — refusing to reconcile with an incomplete "
f"won't-fix suppression set"
)
return issues

def create_issue(self, title: str, body: str) -> Optional[int]:
out = self._run(
["issue", "create", "--title", title, "--body-file", "-"],
Expand Down Expand Up @@ -288,7 +322,20 @@ def reconcile(scan: Dict[str, Any], stream: str, action_url: str, gh: Gh) -> int
recognizable.add(issue["number"])

matched: set = set()
created = updated = closed = 0
created = updated = closed = skipped = 0

# Build the set of suppressed keys from closed issues marked won't fix.
suppressed: set = set()
for issue in gh.list_closed_issues(stream):
if not any(_is_wont_fix_label(lbl.get("name", "")) for lbl in issue.get("labels", [])):
continue
mk = extract_key(issue.get("body"))
if mk:
suppressed.add(mk)
continue
lk = legacy_key_from_title(issue.get("title", ""))
if lk:
suppressed.add(lk)

# Create / update.
for pkey, vuln in desired.items():
Expand All @@ -300,6 +347,9 @@ def reconcile(scan: Dict[str, Any], stream: str, action_url: str, gh: Gh) -> int
matched.add(issue["number"])
updated += 1
eprint(f"Updated #{issue['number']}: {render_title(stream, vuln)}")
elif pkey in suppressed or legacy_key_for_vuln(stream, vuln) in suppressed:
skipped += 1
eprint(f"Skipped (won't fix): {render_title(stream, vuln)}")
else:
number = gh.create_issue(render_title(stream, vuln), body)
if number is not None:
Expand All @@ -324,7 +374,7 @@ def reconcile(scan: Dict[str, Any], stream: str, action_url: str, gh: Gh) -> int
else:
eprint("Scan incomplete (scan_complete=false): skipping close phase to avoid false closes")

eprint(f"Reconcile summary for {stream}: created={created} updated={updated} closed={closed}")
eprint(f"Reconcile summary for {stream}: created={created} updated={updated} closed={closed} skipped={skipped}")
return 0


Expand Down
Loading