You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@security.apache.org by pk...@apache.org on 2026/10/08 09:22:04 UTC
(security-dash) branch feat/triage-every-state created (now ecbc70a)
This is an automated email from the ASF dual-hosted git repository.
ppkarwasz pushed a change to branch feat/triage-every-state
in repository https://gitbox.apache.org/repos/asf/security-dash.git
at ecbc70a Show the triage form for reports in every state
This branch includes the following new commits:
new ecbc70a Show the triage form for reports in every state
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(security-dash) 01/01: Show the triage form for reports in every state
Posted by pk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
ppkarwasz pushed a commit to branch feat/triage-every-state
in repository https://gitbox.apache.org/repos/asf/security-dash.git
commit ecbc70af7681e5d075e74202d1e396c8f2936e08
Author: Piotr P. Karwasz <pi...@github.copernik.eu>
AuthorDate: Thu Oct 8 11:21:56 2026 +0200
Show the triage form for reports in every state
The triage form was only offered on untriaged reports.
PMCs also need to answer the reporter for reports in other states,
e.g. non-issue-feedback, non-issue-docs or cve-allocation.
A report tentatively marked as a non-issue may still become a CVE,
so every state gets the same accept/reject form.
The per-state form mapping is removed, the template is renamed to
includes/forms/triage.html, and the API accepts decisions on reports
in any state.
Assisted-By: Claude Opus 5.5 <no...@anthropic.com>
---
app/__init__.py | 13 ++++----
.../includes/forms/{untriaged.html => triage.html} | 4 +--
app/templates/includes/reports.html | 6 ++--
app/test_project_view.py | 4 +--
app/test_triage.py | 19 ++++--------
app/triage.py | 36 ++--------------------
6 files changed, 21 insertions(+), 61 deletions(-)
diff --git a/app/__init__.py b/app/__init__.py
index e8798ad..c507fd3 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -169,30 +169,29 @@ async def _audit_access(project: str):
print(f"User {user.uid} accessed project {project}{mark}")
-def _sections(project: str, r: list[reports.Report]) -> list[tuple[str, str, str, str | None, list[reports.Report]]]:
- """Reports grouped by state, with the form to show for each state, if any."""
+def _sections(r: list[reports.Report]) -> list[tuple[str, str, str, list[reports.Report]]]:
+ """Reports grouped by state."""
states = sorted(dict.fromkeys(report.state for report in r), key=_state_sort_key)
return [
(
state,
_state_title(state),
_state_description(state),
- triage.form_template(project, state),
[report for report in r if report.state == state],
)
for state in states
]
async def _incubator(podlings: list[str]):
- podling_sections = {podling: _sections(podling, await reports.load_pmc_reports(podling)) for podling in podlings}
+ podling_sections = {podling: _sections(await reports.load_pmc_reports(podling)) for podling in podlings}
untriaged = {
- podling: [report for state, _, _, _, r in sections if state == "untriaged" for report in r]
+ podling: [report for state, _, _, r in sections if state == "untriaged" for report in r]
for podling, sections in podling_sections.items()
}
return await quart.render_template("incubator.html",
untriaged_description=_state_description("untriaged"),
summary=sorted(
- ((podling, len(r), sum(len(s) for _, _, _, _, s in podling_sections[podling]),
+ ((podling, len(r), sum(len(s) for _, _, _, s in podling_sections[podling]),
min((report.date for report in r), default=None))
for podling, r in untriaged.items()),
key=lambda row: (-row[1], -row[2], row[0])),
@@ -211,7 +210,7 @@ async def project(project: str):
return await quart.render_template("project.html",
project_name=project,
debt_constant=statistics.DEBT_CONSTANT,
- sections=_sections(project, r),
+ sections=_sections(r),
max_feedback_length=triage.MAX_FEEDBACK_LENGTH,
message_preview=triage.message_preview,
show_subproject=project in config.get().pmcs_with_subprojects or project == "security")
diff --git a/app/templates/includes/forms/untriaged.html b/app/templates/includes/forms/triage.html
similarity index 93%
rename from app/templates/includes/forms/untriaged.html
rename to app/templates/includes/forms/triage.html
index 8bdaccf..be65f49 100644
--- a/app/templates/includes/forms/untriaged.html
+++ b/app/templates/includes/forms/triage.html
@@ -1,5 +1,5 @@
-{# Triage form for an untriaged report. The PMC picks a decision and writes the
- feedback; both are handed to the notification API, which sends the email
+{# Triage form for a report, whatever its state.
+ The PMC picks a decision and writes the feedback; both are handed to the notification API, which sends the email
previewed here. The static parts come from triage.message_preview(). #}
{% set preview = message_preview(project_name, report, current_user.fullname or current_user.uid) %}
<details class="triage">
diff --git a/app/templates/includes/reports.html b/app/templates/includes/reports.html
index 2a160d7..1624bf2 100644
--- a/app/templates/includes/reports.html
+++ b/app/templates/includes/reports.html
@@ -2,7 +2,7 @@
Expects: sections, project_name, show_subproject, and optionally anchor_prefix;
the triage forms also need max_feedback_length, message_preview
and optionally triage_view, the page they return to. -#}
-{% for state, title, description, form_template, reports in sections %}
+{% for state, title, description, reports in sections %}
<h3 id="{{ anchor_prefix|default("") }}{{ state }}">{{ title }}
{% if reports|length > 5 %}
({{ reports|length }})
@@ -56,11 +56,9 @@
</details>
{% endif %}
</td>
- {% if form_template %}
<td class="reports-triage">
- {% include form_template %}
+ {% include "includes/forms/triage.html" %}
</td>
- {% endif %}
</tr>
{% endfor %}
</tbody>
diff --git a/app/test_project_view.py b/app/test_project_view.py
index 09f770a..61fcef1 100644
--- a/app/test_project_view.py
+++ b/app/test_project_view.py
@@ -174,7 +174,7 @@ async def test_preview_falls_back_to_the_asf_id_when_the_name_is_unknown(quart_a
@sync
-async def test_confirmed_report_gets_no_form(tmp_path, monkeypatch):
+async def test_confirmed_report_gets_a_triage_form(tmp_path, monkeypatch):
quart_app = _build_app(tmp_path, monkeypatch, label="CVE-2024-1234 a flaw")
_login(monkeypatch)
@@ -182,7 +182,7 @@ async def test_confirmed_report_gets_no_form(tmp_path, monkeypatch):
body = await response.get_data(as_text=True)
assert response.status_code == 200
- assert "Triage this report" not in body
+ assert 'action="/api/project/cassandra/triage"' in body
@sync
diff --git a/app/test_triage.py b/app/test_triage.py
index 6a527e5..2f3f256 100644
--- a/app/test_triage.py
+++ b/app/test_triage.py
@@ -105,13 +105,12 @@ def test_report_from_another_project_is_not_found():
assert e.value.status == 404
-def test_report_in_a_state_without_a_form_cannot_be_triaged():
- with pytest.raises(TriageError) as e:
- _decide(
- {"message_id": "<ab...@cassandra.apache.org>", "action": "accept", "tag": "2024-03-01 a flaw"},
- candidates=[_report(state="confirmed")],
- )
- assert e.value.status == 409
+def test_report_in_any_state_can_be_triaged():
+ decision = _decide(
+ {"message_id": "<ab...@cassandra.apache.org>", "action": "reject", "tag": "2024-03-01 a flaw"},
+ candidates=[_report(state="non-issue-feedback")],
+ )
+ assert decision.action == "reject"
def test_overlong_feedback_is_rejected():
@@ -130,12 +129,6 @@ def test_non_text_feedback_is_rejected():
_decide({"message_id": "<ab...@cassandra.apache.org>", "action": "accept", "tag": "2024-03-01 a flaw", "feedback": {"a": 1}})
-def test_untriaged_has_a_form_and_other_states_do_not():
- assert triage.form_template("cassandra", "untriaged") == "includes/forms/untriaged.html"
- assert triage.form_template("cassandra", "confirmed") is None
- assert triage.form_template("cassandra", "non-issue-docs") is None
-
-
class _FakeResponse:
"""Just enough of an aiohttp response for `submit`."""
diff --git a/app/triage.py b/app/triage.py
index 024e7ea..d2d8e64 100644
--- a/app/triage.py
+++ b/app/triage.py
@@ -22,9 +22,8 @@ notification API, which emails the feedback to the reporter and moves the report
on (rejected reports end up in the zzz-non-issue tree, so they drop off this
dashboard entirely).
-Which form a report gets, and which actions that form may submit, depend on the
-state the report is in: both live in `_STATE_FORMS` below, so adding a form for
-another state is a matter of adding an entry and a template.
+Every report gets the same form, whatever its state: a report tentatively marked
+as a non-issue may still turn out to be a vulnerability, and vice versa.
"""
from app import config
@@ -41,18 +40,6 @@ class TriageAction(enum.StrEnum):
ACCEPT = "accept"
REJECT = "reject"
-@dataclasses.dataclass(frozen=True)
-class _StateForm:
- template: str
- actions: frozenset[TriageAction]
-
-_STATE_FORMS: dict[str, _StateForm] = {
- "untriaged": _StateForm(
- template="includes/forms/untriaged.html",
- actions=frozenset({TriageAction.ACCEPT, TriageAction.REJECT}),
- ),
-}
-
MAX_FEEDBACK_LENGTH = 10_000
# The notification API owns the real email template; these are the static parts
@@ -143,11 +130,6 @@ class TriageError(Exception):
class TriageUnavailable(Exception):
"""The decision was valid, but could not be handed to the notification API."""
-def form_template(project: str, state: str) -> str | None:
- """The form to show for a report of `project` in `state`."""
- form = _STATE_FORMS.get(state)
- return form.template if form else None
-
@dataclasses.dataclass(frozen=True)
class TriageDecision:
project: str
@@ -179,7 +161,7 @@ def parse_decision(
The report is looked up among `candidates` rather than trusted from the
payload, so a decision can only ever apply to a report of the project the
- user was authorized for, in a state that has a form.
+ user was authorized for.
"""
message_id = _required_string(payload, "message_id")
tag = _required_string(payload, "tag")
@@ -196,18 +178,6 @@ def parse_decision(
raise TriageError(f"report {message_id!r} in {project} not unique", status=400)
report = matches[0]
- form = _STATE_FORMS.get(report.state)
- if form is None:
- raise TriageError(
- f"report {message_id!r} is in state {report.state!r}, which cannot be triaged",
- status=409,
- )
- if action not in form.actions:
- raise TriageError(
- f"cannot {action} a report in state {report.state!r}",
- status=409,
- )
-
feedback = payload.get("feedback") or ""
if not isinstance(feedback, str):
raise TriageError("'feedback' must be text")