blob: 9fb00292c53982a759212a79fe192e3985669511 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Pure classifier for the bulk-mode pre-flight no-op skip decision.
This module is the executable spec of the rule table documented in
[`.claude/skills/security-issue-sync/bulk-mode.md`](../../../.claude/skills/security-issue-sync/bulk-mode.md).
Both representations must stay in sync — a PR that changes one
should change the other.
The classifier is intentionally split from the fetch layer
(:mod:`preflight_audit.fetch`) so tests can drive it with synthetic
issue dicts and replays of canned GraphQL responses, without any
network calls.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
# The skill-marker prefix every framework-authored tracker comment
# carries (status-rollup, release-manager hand-off, wrap-up). Keeps
# the classifier in lock-step with `tools/github/status-rollup.md`.
SKILL_MARKER_PREFIX = "<!-- apache-magpie: "
# Bot logins always treated as bot-equivalent regardless of comment body.
# Extend per-adopter via the override file (see bulk-mode.md).
_BUILTIN_BOT_LOGINS: frozenset[str] = frozenset(
{
"github-actions[bot]",
"dependabot[bot]",
}
)
class Decision(StrEnum):
"""The three possible classifier outcomes."""
DISPATCH = "dispatch"
DISPATCH_URGENT = "dispatch-urgent"
SKIP_NOOP = "skip-noop"
@dataclass(frozen=True)
class IssueState:
"""Lightweight snapshot the classifier needs for one tracker.
Mirrors the GraphQL response shape produced by
:func:`preflight_audit.fetch.build_query` so the fetch layer can
instantiate these directly from `gh api graphql` output.
"""
number: int
state: str # "OPEN" | "CLOSED"
closed_at: datetime | None
updated_at: datetime
labels: frozenset[str]
last_comment_author: str | None
last_comment_created_at: datetime | None
last_comment_body: str | None
@dataclass(frozen=True)
class Classification:
"""The classifier's output for one tracker."""
issue: IssueState
decision: Decision
reason: str
last_is_skill_or_bot: bool
def _is_skill_or_bot(
login: str | None,
body: str | None,
extra_bot_logins: frozenset[str],
) -> bool:
"""Return True when the comment counts as bot-equivalent.
Three signals (any one is enough):
1. The login is a literal GitHub App account (ends in ``[bot]``
or matches a built-in / override-listed bot login).
2. The body begins with the framework's skill marker — see
:data:`SKILL_MARKER_PREFIX`. This is the signal that catches
sync-skill writes on single-operator trackers where the skill
runs under the operator's own user account.
3. The login is in the adopter's override-supplied
``extra_bot_logins`` set (for personal-account bots).
"""
if login is not None:
if login in _BUILTIN_BOT_LOGINS or login in extra_bot_logins:
return True
if login.endswith("[bot]"):
return True
return bool(body is not None and body.lstrip().startswith(SKILL_MARKER_PREFIX))
def _days_between(now: datetime, then: datetime | None) -> float | None:
if then is None:
return None
return (now - then).total_seconds() / 86400
def classify_issue(
iss: IssueState,
now: datetime,
extra_bot_logins: frozenset[str] = frozenset(),
) -> Classification:
"""Apply the rule table to one tracker.
Rules are checked **in order** — the first match wins. This is
the executable spec of the table in
`bulk-mode.md` § Pre-flight no-op classifier.
"""
last_was_skill = _is_skill_or_bot(
iss.last_comment_author,
iss.last_comment_body,
extra_bot_logins,
)
updated_age = _days_between(now, iss.updated_at)
last_comment_age = _days_between(now, iss.last_comment_created_at)
closed_age = _days_between(now, iss.closed_at)
# Rule 1: 7-day updatedAt safety override — but only when the
# recent activity wasn't itself a skill write. On a tracker the
# skill just touched, the recently-bumped updatedAt is the skill's
# own work; let downstream rules decide.
if updated_age is not None and updated_age < 7:
skill_drove_recent_update = last_was_skill and last_comment_age is not None and last_comment_age < 7
if not skill_drove_recent_update:
return Classification(
issue=iss,
decision=Decision.DISPATCH,
reason=f"recent human activity (updatedAt {int(updated_age)}d)",
last_is_skill_or_bot=last_was_skill,
)
# Rule 2: dispatch-urgent — a non-skill comment in the last 24h.
if last_comment_age is not None and last_comment_age < 1 and not last_was_skill:
return Classification(
issue=iss,
decision=Decision.DISPATCH_URGENT,
reason=f"recent reply from {iss.last_comment_author} (<24h)",
last_is_skill_or_bot=last_was_skill,
)
# Rule 3: closed > 30d ago AND `announced` label → post-announce.
if iss.state == "CLOSED" and closed_age is not None and closed_age > 30 and "announced" in iss.labels:
return Classification(
issue=iss,
decision=Decision.SKIP_NOOP,
reason=f"post-announce; closed {int(closed_age)}d ago",
last_is_skill_or_bot=last_was_skill,
)
# Rule 4: closed > 90d ago with no `announced` → stale invalid/dup.
if iss.state == "CLOSED" and closed_age is not None and closed_age > 90 and "announced" not in iss.labels:
return Classification(
issue=iss,
decision=Decision.SKIP_NOOP,
reason=f"stale closed {int(closed_age)}d ago (no announce)",
last_is_skill_or_bot=last_was_skill,
)
# Rule 5: open + full lifecycle + skill last → awaiting closure.
full_set = {"cve allocated", "pr merged", "announced"}
if iss.state == "OPEN" and full_set.issubset(iss.labels) and last_was_skill:
age_s = f"{int(last_comment_age)}d" if last_comment_age is not None else "?"
return Classification(
issue=iss,
decision=Decision.SKIP_NOOP,
reason=f"all phases done; skill-last ({age_s})",
last_is_skill_or_bot=last_was_skill,
)
# Rule 6: open + cve+pr+skill-last → awaiting release.
if iss.state == "OPEN" and {"cve allocated", "pr merged"}.issubset(iss.labels) and last_was_skill:
age_s = f"{int(last_comment_age)}d" if last_comment_age is not None else "?"
return Classification(
issue=iss,
decision=Decision.SKIP_NOOP,
reason=f"awaiting release; skill-last ({age_s})",
last_is_skill_or_bot=last_was_skill,
)
# Rule 7: open + cve+fix-released+skill-last → awaiting advisory.
if iss.state == "OPEN" and {"cve allocated", "fix released"}.issubset(iss.labels) and last_was_skill:
age_s = f"{int(last_comment_age)}d" if last_comment_age is not None else "?"
return Classification(
issue=iss,
decision=Decision.SKIP_NOOP,
reason=f"fix released; awaiting advisory; skill-last ({age_s})",
last_is_skill_or_bot=last_was_skill,
)
# Fall-through: dispatch.
return Classification(
issue=iss,
decision=Decision.DISPATCH,
reason="-",
last_is_skill_or_bot=last_was_skill,
)
def _parse_iso(s: str | None) -> datetime | None:
if s is None:
return None
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _issue_from_node(node: dict[str, Any]) -> IssueState:
"""Build an :class:`IssueState` from a GraphQL issue node."""
comments = node.get("comments", {}).get("nodes") or []
last = comments[0] if comments else None
last_author = None
last_created = None
last_body = None
if last is not None:
author = last.get("author") or {}
last_author = author.get("login")
last_created = _parse_iso(last.get("createdAt"))
last_body = last.get("body")
label_nodes = node.get("labels", {}).get("nodes") or []
labels = frozenset(n["name"] for n in label_nodes)
return IssueState(
number=node["number"],
state=node["state"],
closed_at=_parse_iso(node.get("closedAt")),
updated_at=_parse_iso(node["updatedAt"]) or datetime.now(UTC),
labels=labels,
last_comment_author=last_author,
last_comment_created_at=last_created,
last_comment_body=last_body,
)
def classify_response(
response: dict[str, Any],
now: datetime,
extra_bot_logins: frozenset[str] = frozenset(),
) -> list[Classification]:
"""Classify every issue in a raw `gh api graphql` response.
Skips null nodes (issues that 404'd) silently — the caller
can audit those by comparing against the input issue list.
"""
repo = response.get("data", {}).get("repository", {}) or {}
out: list[Classification] = []
for _alias, node in repo.items():
if node is None:
continue
iss = _issue_from_node(node)
out.append(classify_issue(iss, now=now, extra_bot_logins=extra_bot_logins))
return out