| # 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 |