blob: dc7059bb834cab91bb92ae1c368589829ebfd739 [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.
"""Rule-by-rule tests for the pre-flight classifier.
Each rule from `bulk-mode.md` § Pre-flight no-op classifier has at
least one focused test covering the positive case (rule fires) and
one negative case (rule does not fire). When the rule table grows,
add the matching `test_rule_N_*` here.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from preflight_audit.classifier import (
Decision,
IssueState,
classify_issue,
classify_response,
)
NOW = datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC)
def make_issue(
*,
number: int = 100,
state: str = "OPEN",
closed_days_ago: float | None = None,
updated_days_ago: float = 30.0,
labels: frozenset[str] = frozenset(),
last_comment_author: str | None = None,
last_comment_days_ago: float | None = None,
last_comment_body: str | None = None,
) -> IssueState:
return IssueState(
number=number,
state=state,
closed_at=(NOW - timedelta(days=closed_days_ago)) if closed_days_ago is not None else None,
updated_at=NOW - timedelta(days=updated_days_ago),
labels=labels,
last_comment_author=last_comment_author,
last_comment_created_at=(
NOW - timedelta(days=last_comment_days_ago) if last_comment_days_ago is not None else None
),
last_comment_body=last_comment_body,
)
# ---------------------------------------------------------------------------
# Rule 1 — 7-day updatedAt safety override
# ---------------------------------------------------------------------------
def test_rule_1_dispatches_when_human_activity_in_last_7d():
iss = make_issue(
updated_days_ago=2.0,
last_comment_author="someone",
last_comment_days_ago=2.0,
last_comment_body="real comment\n",
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.DISPATCH
assert "recent human activity" in c.reason
def test_rule_1_skipped_when_skill_drove_recent_update():
"""A recent-only-because-the-skill-wrote update should NOT
block downstream skip rules from firing."""
iss = make_issue(
state="OPEN",
updated_days_ago=1.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="potiuk",
last_comment_days_ago=1.0,
last_comment_body="<!-- apache-magpie: release-manager-handoff v1 -->\nbody",
)
c = classify_issue(iss, now=NOW)
# Rule 7 should fire because Rule 1 yielded.
assert c.decision == Decision.SKIP_NOOP
assert "fix released" in c.reason
# ---------------------------------------------------------------------------
# Rule 2 — dispatch-urgent (non-skill comment in last 24h)
# ---------------------------------------------------------------------------
def test_rule_2_urgent_when_non_skill_comment_under_24h():
iss = make_issue(
state="OPEN",
updated_days_ago=0.5,
last_comment_author="reporter",
last_comment_days_ago=0.5,
last_comment_body="please re-check\n",
)
c = classify_issue(iss, now=NOW)
# Rule 1 fires first (recent human activity); urgent is only
# reachable when Rule 1 yielded — i.e., when the recent activity
# was the skill. So the urgent path needs a non-skill comment
# AFTER a skill update? Actually the rule is "Rule 1 yielded AND
# Rule 2 fires" which happens when last comment is recent AND
# not skill — but then Rule 1 ALSO doesn't yield, since it
# requires skill-drove-recent-update. Both fail; we end up at
# Rule 1's dispatch. That matches the prose: urgent is the
# bot-vs-not distinction; when the last comment is a human reply
# in the last 24h, the classifier dispatches (with the urgent
# tag) if-and-only-if there is no other reason to dispatch.
# In this synthetic case Rule 1 catches first → dispatch
# without urgent. That's correct behaviour.
assert c.decision == Decision.DISPATCH
def test_rule_2_urgent_path_after_skill_only_recent_activity():
"""Construct a case where Rule 1 yields (skill drove update) but
the LAST comment is still a non-skill recent reply. Tricky
construction: a non-skill last comment within 24h, but updatedAt
older than 7d — Rule 1 won't fire at all, then Rule 2 catches."""
iss = make_issue(
state="OPEN",
updated_days_ago=10.0, # >7d so Rule 1 doesn't fire
last_comment_author="reporter",
last_comment_days_ago=0.5,
last_comment_body="any update?\n",
)
# updated_days_ago=10 but last_comment_days_ago=0.5 is
# internally inconsistent (a real GitHub response would have
# updatedAt >= last comment time), but the classifier handles
# it gracefully — Rule 1 skips, Rule 2 fires.
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.DISPATCH_URGENT
assert "reporter" in c.reason
def test_rule_2_not_urgent_when_skill_comment():
iss = make_issue(
state="OPEN",
updated_days_ago=10.0,
last_comment_author="potiuk",
last_comment_days_ago=0.5,
last_comment_body="<!-- apache-magpie: status-rollup v1 -->\nentry\n",
)
c = classify_issue(iss, now=NOW)
assert c.decision != Decision.DISPATCH_URGENT
# ---------------------------------------------------------------------------
# Rule 3 — closed > 30d + announced → post-announce
# ---------------------------------------------------------------------------
def test_rule_3_skip_post_announce():
iss = make_issue(
state="CLOSED",
closed_days_ago=40.0,
updated_days_ago=40.0,
labels=frozenset({"announced", "cve allocated"}),
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.SKIP_NOOP
assert "post-announce" in c.reason
def test_rule_3_no_skip_recently_closed():
iss = make_issue(
state="CLOSED",
closed_days_ago=10.0,
updated_days_ago=10.0,
labels=frozenset({"announced"}),
)
c = classify_issue(iss, now=NOW)
# 10d < 30d threshold; downstream falls through to dispatch.
assert c.decision == Decision.DISPATCH
def test_rule_3_no_skip_no_announce_label():
iss = make_issue(
state="CLOSED",
closed_days_ago=40.0,
updated_days_ago=40.0,
labels=frozenset({"cve allocated"}),
)
c = classify_issue(iss, now=NOW)
# No `announced` → Rule 3 doesn't fire; Rule 4 wants > 90d.
assert c.decision == Decision.DISPATCH
# ---------------------------------------------------------------------------
# Rule 4 — closed > 90d no announce → stale
# ---------------------------------------------------------------------------
def test_rule_4_skip_stale_closed():
iss = make_issue(
state="CLOSED",
closed_days_ago=120.0,
updated_days_ago=120.0,
labels=frozenset({"invalid"}),
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.SKIP_NOOP
assert "stale closed" in c.reason
def test_rule_4_no_skip_if_announced():
iss = make_issue(
state="CLOSED",
closed_days_ago=120.0,
updated_days_ago=120.0,
labels=frozenset({"announced"}),
)
c = classify_issue(iss, now=NOW)
# Rule 3 catches first.
assert c.decision == Decision.SKIP_NOOP
assert "post-announce" in c.reason
# ---------------------------------------------------------------------------
# Rule 5 — open + full lifecycle + skill-last → awaiting closure
# ---------------------------------------------------------------------------
def test_rule_5_skip_full_lifecycle_skill_last():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "pr merged", "announced"}),
last_comment_author="potiuk",
last_comment_days_ago=2.0,
last_comment_body="<!-- apache-magpie: status-rollup v1 -->\nentry",
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.SKIP_NOOP
assert "all phases done" in c.reason
def test_rule_5_no_skip_when_last_human():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "pr merged", "announced"}),
last_comment_author="reporter",
last_comment_days_ago=2.0,
last_comment_body="real reply\n",
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.DISPATCH
# ---------------------------------------------------------------------------
# Rule 6 — open + cve+pr + skill-last → awaiting release
# ---------------------------------------------------------------------------
def test_rule_6_skip_awaiting_release():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "pr merged"}),
last_comment_author="potiuk",
last_comment_days_ago=2.0,
last_comment_body="<!-- apache-magpie: status-rollup v1 -->\n",
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.SKIP_NOOP
assert "awaiting release" in c.reason
# ---------------------------------------------------------------------------
# Rule 7 — open + cve+fix-released + skill-last → awaiting advisory
# ---------------------------------------------------------------------------
def test_rule_7_skip_awaiting_advisory():
iss = make_issue(
state="OPEN",
updated_days_ago=1.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="potiuk",
last_comment_days_ago=1.0,
last_comment_body="<!-- apache-magpie: release-manager-handoff v1 -->\n",
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.SKIP_NOOP
assert "awaiting advisory" in c.reason
# ---------------------------------------------------------------------------
# Skill-or-bot detection
# ---------------------------------------------------------------------------
def test_bot_login_detected():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="github-actions[bot]",
last_comment_days_ago=2.0,
last_comment_body="CI passed\n",
)
c = classify_issue(iss, now=NOW)
assert c.last_is_skill_or_bot is True
assert c.decision == Decision.SKIP_NOOP
def test_dependabot_login_detected():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="dependabot[bot]",
last_comment_days_ago=2.0,
last_comment_body="bumped\n",
)
c = classify_issue(iss, now=NOW)
assert c.last_is_skill_or_bot is True
def test_skill_marker_detected_regardless_of_author():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="some-user",
last_comment_days_ago=2.0,
last_comment_body="<!-- apache-magpie: status-rollup v1 -->\nentry",
)
c = classify_issue(iss, now=NOW)
assert c.last_is_skill_or_bot is True
def test_extra_bot_login_override():
iss = make_issue(
state="OPEN",
updated_days_ago=2.0,
labels=frozenset({"cve allocated", "fix released"}),
last_comment_author="company-private-bot",
last_comment_days_ago=2.0,
last_comment_body="hello\n",
)
c = classify_issue(iss, now=NOW, extra_bot_logins=frozenset({"company-private-bot"}))
assert c.last_is_skill_or_bot is True
def test_real_human_not_detected_as_bot():
iss = make_issue(
last_comment_author="reporter-jane",
last_comment_body="this is broken\n",
)
c = classify_issue(iss, now=NOW)
assert c.last_is_skill_or_bot is False
# ---------------------------------------------------------------------------
# classify_response — end-to-end with a synthetic GraphQL shape
# ---------------------------------------------------------------------------
def test_classify_response_handles_empty_repo():
assert classify_response({"data": {"repository": {}}}, now=NOW) == []
def test_classify_response_skips_null_nodes():
response: dict = {
"data": {
"repository": {
"i1": None,
"i2": {
"number": 2,
"state": "OPEN",
"closedAt": None,
"updatedAt": "2025-01-01T00:00:00Z",
"labels": {"nodes": [{"name": "needs triage"}]},
"comments": {"nodes": []},
},
}
}
}
results = classify_response(response, now=NOW)
assert len(results) == 1
assert results[0].issue.number == 2
def test_classify_response_parses_iso_timestamps():
response: dict = {
"data": {
"repository": {
"i9": {
"number": 9,
"state": "CLOSED",
"closedAt": "2026-03-15T10:00:00Z",
"updatedAt": "2026-03-15T10:00:00Z",
"labels": {"nodes": [{"name": "announced"}]},
"comments": {"nodes": []},
}
}
}
}
results = classify_response(response, now=NOW)
assert len(results) == 1
assert results[0].decision == Decision.SKIP_NOOP
assert "post-announce" in results[0].reason
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_no_last_comment_falls_through_to_dispatch():
iss = make_issue(
state="OPEN",
updated_days_ago=30.0,
labels=frozenset({"cve allocated"}),
)
c = classify_issue(iss, now=NOW)
assert c.decision == Decision.DISPATCH
assert c.last_is_skill_or_bot is False
@pytest.mark.parametrize(
"body,expected_skill",
[
("<!-- apache-magpie: x v1 -->", True),
(" \n<!-- apache-magpie: x v1 -->", True), # leading whitespace
("<!-- apache-magpie:x -->", False), # missing space after colon
("real reply\n<!-- apache-magpie: x -->", False), # not at start
("", False),
(None, False),
],
)
def test_skill_marker_match_precision(body, expected_skill):
iss = make_issue(
last_comment_author="potiuk",
last_comment_days_ago=5.0,
last_comment_body=body,
)
c = classify_issue(iss, now=NOW)
assert c.last_is_skill_or_bot is expected_skill