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/09/29 20:52:59 UTC

(security-dash) branch frontend updated: feat: summarize incubating projects on the incubator page (#34)

This is an automated email from the ASF dual-hosted git repository.

ppkarwasz pushed a commit to branch frontend
in repository https://gitbox.apache.org/repos/asf/security-dash.git


The following commit(s) were added to refs/heads/frontend by this push:
     new ed03530  feat: summarize incubating projects on the incubator page (#34)
ed03530 is described below

commit ed03530665fbaf1912b3cf5b957ac861fd08c0bf
Author: Piotr P. Karwasz <pk...@apache.org>
AuthorDate: Tue Sep 29 22:52:53 2026 +0200

    feat: summarize incubating projects on the incubator page (#34)
    
    * feat: summarize incubating projects on the incubator page
    
    All security reports for incubating projects are assigned to the podlings,
    so the `incubator` project page was always empty.
    
    The incubator page now shows:
    
    - A sortable summary table listing, for each podling, the number of
      untriaged and open reports and the date of the oldest untriaged report.
      It is initially sorted by untriaged, then total, reports.
    - The reports of each podling, grouped by state as on the project page.
    
    The podlings are configured with the new `pmcs_in_incubator` setting.
    
    Fixes #29
    
    Assisted-By: Claude Opus 5.5 <no...@anthropic.com>
    
    * feat: triage podlings from the incubator page
    
    The incubator page now shows the triage forms of the podling reports:
    
    - The form template of each state is no longer tied to triage.
    - The Incubator PMC is authorized for the podlings it oversees,
      so its members can triage them.
    - A form posted from the incubator page returns to it.
    
    Assisted-By: Claude Opus 5.5 <no...@anthropic.com>
---
 app/__init__.py                             |  51 +++++++++--
 app/config.py                               |   3 +
 app/static/assets/css/app.css               |  48 ++++++++++
 app/static/assets/js/sortable-table.js      |  68 ++++++++++++++
 app/templates/includes/forms/untriaged.html |   2 +-
 app/templates/includes/reports.html         |  68 ++++++++++++++
 app/templates/incubator.html                |  74 ++++++++++++++++
 app/templates/project.html                  |  65 +-------------
 app/test_incubator_view.py                  | 133 ++++++++++++++++++++++++++++
 config.yaml.example                         |   3 +
 10 files changed, 441 insertions(+), 74 deletions(-)

diff --git a/app/__init__.py b/app/__init__.py
index 29172c9..726ba85 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -112,7 +112,10 @@ async def _require_authorization_for(project: str) -> utils.UserSession:
         raise asfquart.auth.AuthenticationFailed(asfquart.auth.Requirements.E_NOT_LOGGED_IN)
     pmcs = user.accessible_pmcs
     if (not _asf_group_acl(project, pmcs, user.projects)
-        and not _asf_group_acl("security", pmcs, user.projects)):
+        and not _asf_group_acl("security", pmcs, user.projects)
+        # the Incubator PMC oversees its podlings
+        and not (project in config.get().pmcs_in_incubator
+                 and _asf_group_acl("incubator", pmcs, user.projects))):
         raise asfquart.auth.AuthenticationFailed(f"You are not a member of the {project} PMC.")
     return user
 
@@ -163,14 +166,12 @@ async def _audit_access(project: str):
 
     print(f"User {user.uid} accessed project {project}{mark}")
 
-@CLIENT.route("/project/<project>")
-async def project(project: str):
-    await _require_authorization_for(project)
-    await _audit_access(project)
-    r = await reports.load_pmc_reports(project)
+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."""
     states = sorted(dict.fromkeys(report.state for report in r), key=_state_sort_key)
-    sections = [
+    return [
         (
+            state,
             _state_title(state),
             _state_description(state),
             triage.form_template(project, state),
@@ -178,10 +179,36 @@ async def project(project: str):
         )
         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}
+    untriaged = {
+        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]),
+              min((report.date for report in r), default=None))
+             for podling, r in untriaged.items()),
+            key=lambda row: (-row[1], -row[2], row[0])),
+        podling_sections=podling_sections,
+        max_feedback_length=triage.MAX_FEEDBACK_LENGTH,
+        message_preview=triage.message_preview,
+        pmcs_with_subprojects=config.get().pmcs_with_subprojects)
+
+@CLIENT.route("/project/<project>")
+async def project(project: str):
+    await _require_authorization_for(project)
+    await _audit_access(project)
+    if project == "incubator":
+        return await _incubator(config.get().pmcs_in_incubator)
+    r = await reports.load_pmc_reports(project)
     return await quart.render_template("project.html",
         project_name=project,
         debt_constant=statistics.DEBT_CONSTANT,
-        sections=sections,
+        sections=_sections(project, 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")
@@ -261,7 +288,13 @@ async def _triage_response(project: str, message: str, category: str, status: in
     if _wants_json():
         return quart.jsonify({"status": category, "message": message, **details}), status
     await quart.flash(message, category)
-    return quart.redirect(quart.url_for("client.project", project=project), code=303)
+    return quart.redirect(_triage_view(project), code=303)
+
+def _triage_view(project: str) -> str:
+    """The page a triage form returns to: the incubator page for a podling triaged from there, else the project's own."""
+    if quart.request.args.get("view") == "incubator" and project in config.get().pmcs_in_incubator:
+        return quart.url_for("client.project", project="incubator")
+    return quart.url_for("client.project", project=project)
 
 def _register_routes(quart_app: asfquart.base.QuartApp) -> None:
     quart_app.register_blueprint(CLIENT)
diff --git a/app/config.py b/app/config.py
index 2134be1..64dbbfa 100644
--- a/app/config.py
+++ b/app/config.py
@@ -70,6 +70,9 @@ class AppConfig(pydantic.BaseModel):
     oauth_enforce_https: bool = True
     """enforce HTTPS in the callback to the relying party."""
 
+    pmcs_in_incubator: list[str] = []
+    """Podlings in the Incubator, summarized on the incubator page."""
+
     server: ServerConfig = ServerConfig()
 
     backend: str = "http://localhost:5002"
diff --git a/app/static/assets/css/app.css b/app/static/assets/css/app.css
index bd7b393..bf5c7aa 100644
--- a/app/static/assets/css/app.css
+++ b/app/static/assets/css/app.css
@@ -350,3 +350,51 @@ footer p {
   font: inherit;
   cursor: pointer;
 }
+
+.incubator-table {
+  width: auto;
+  border: 1px solid #d1d2d3;
+}
+
+.incubator-table th,
+.incubator-table td {
+  border: 1px solid #d1d2d3;
+  padding: 0.25rem 0.75rem;
+  text-align: left;
+}
+
+.incubator-table thead th {
+  background-color: #eeeeee;
+}
+
+.incubator-table tbody tr:nth-child(even) {
+  background-color: #f8f9fa;
+}
+
+.incubator-table .incubator-count {
+  text-align: right;
+}
+
+.sortable-table th button {
+  background: none;
+  border: none;
+  padding: 0;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+}
+
+.sortable-table th button::after {
+  content: " \2195";
+  color: #999999;
+}
+
+.sortable-table th[aria-sort="ascending"] button::after {
+  content: " \25B2";
+  color: inherit;
+}
+
+.sortable-table th[aria-sort="descending"] button::after {
+  content: " \25BC";
+  color: inherit;
+}
diff --git a/app/static/assets/js/sortable-table.js b/app/static/assets/js/sortable-table.js
new file mode 100644
index 0000000..a67ddaa
--- /dev/null
+++ b/app/static/assets/js/sortable-table.js
@@ -0,0 +1,68 @@
+// 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.
+
+// Sorts the rows of `table.sortable-table` when a header button is clicked.
+// A cell sorts by its `data-sort-value`, or else its text; a header with
+// `data-sort-type="number"` sorts numerically. Empty cells always sort last.
+// The current order is exposed to assistive technology through `aria-sort`.
+(function () {
+  "use strict";
+
+  function sortValue(row, index) {
+    var cell = row.cells[index];
+    var value = cell.dataset.sortValue;
+    return (value !== undefined ? value : cell.textContent).trim();
+  }
+
+  function sortBy(table, th) {
+    var headers = th.parentElement.children;
+    var index = Array.prototype.indexOf.call(headers, th);
+    var numeric = th.dataset.sortType === "number";
+    var ascending = th.getAttribute("aria-sort") !== "ascending";
+    var tbody = table.tBodies[0];
+
+    var rows = Array.prototype.slice.call(tbody.rows);
+    rows.sort(function (a, b) {
+      var x = sortValue(a, index);
+      var y = sortValue(b, index);
+      if (x === "" || y === "") {
+        return (x === "") - (y === "");
+      }
+      var order = numeric ? Number(x) - Number(y) : x.localeCompare(y);
+      return ascending ? order : -order;
+    });
+    rows.forEach(function (row) {
+      tbody.appendChild(row);
+    });
+
+    Array.prototype.forEach.call(headers, function (header) {
+      header.removeAttribute("aria-sort");
+    });
+    th.setAttribute("aria-sort", ascending ? "ascending" : "descending");
+  }
+
+  document.querySelectorAll("table.sortable-table").forEach(function (table) {
+    table.querySelectorAll("thead th").forEach(function (th) {
+      var button = th.querySelector("button");
+      if (button) {
+        button.addEventListener("click", function () {
+          sortBy(table, th);
+        });
+      }
+    });
+  });
+})();
diff --git a/app/templates/includes/forms/untriaged.html b/app/templates/includes/forms/untriaged.html
index 26eed3d..8bdaccf 100644
--- a/app/templates/includes/forms/untriaged.html
+++ b/app/templates/includes/forms/untriaged.html
@@ -5,7 +5,7 @@
 <details class="triage">
   <summary>Triage</summary>
   <form method="post"
-        action="{{ url_for('client.project_triage_api', project=project_name) }}">
+        action="{{ url_for('client.project_triage_api', project=project_name, view=triage_view|default(none)) }}">
     <input type="hidden" name="tag" value="{{ report.security_team_name }}" />
     <input type="hidden" name="message_id" value="{{ report.message_id }}" />
 
diff --git a/app/templates/includes/reports.html b/app/templates/includes/reports.html
new file mode 100644
index 0000000..2a160d7
--- /dev/null
+++ b/app/templates/includes/reports.html
@@ -0,0 +1,68 @@
+{#- Sections of reports grouped by state.
+    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 %}
+  <h3 id="{{ anchor_prefix|default("") }}{{ state }}">{{ title }}
+    {% if reports|length > 5 %}
+    ({{ reports|length }})
+    {% endif %}
+  </h3>
+  {% if description %}
+    <p>{{ description }}</p>
+  {% endif %}
+  <table class="reports-table">
+    <tbody>
+      {% for report in reports|sort(attribute='date') %}
+      <tr>
+        <!-- {{ report.security_team_name }} -->
+        <td class="reports-date">{{ report.date.strftime('%Y-%m-%d') }}</td>
+        {% if show_subproject %}
+        <td class="reports-subproject">{{ report.subproject or "" }}</td>
+        {% endif %}
+        <td class="reports-jira">
+          {%- if report.github -%}
+            <a href="{{report.github[1]}}">{{ report.github[0] }}</a>
+          {%- endif -%}
+          {%- if report.jira -%}
+            <a href="https://issues.apache.org/jira/browse/{{report.jira}}">#{{ report.jira.split('-')[-1] }}</a>
+          {%- endif -%}
+        </td>
+        <td class="reports-cves">
+          {%- for cve in report.cves -%}
+            <div><a href="https://cveprocess.apache.org/cve5/{{cve}}">{{ cve }}</a></div>
+          {%- endfor -%}
+          {%- if report.state == 'confirmed' and not report.cves -%}
+            <div><a href="https://cveprocess.apache.org/allocatecve?pmc={{ project_name|urlencode }}&amp;title={{ report.sanitized_title|urlencode }}&amp;messageid={{ report.message_id|urlencode }}&amp;listid={{ report.listid|urlencode }}">allocate CVE</a></div>
+          {%- endif -%}
+        </td>
+        <td class="reports-reporter">
+          {% if report.reporter %}
+            <span class="reporter-initials"
+                  style="background-color: {{ report.reporter.color }};"
+                  title="{{ report.reporter.tooltip }}">{{ report.reporter.initials }}</span>
+          {% endif %}
+        </td>
+        <td class="reports-title">
+          <a href="{{ report.link }}" title="{{ report.title }}">{{ report.title }}</a>
+          {% if report.duplicates %}
+          <details class="reports-duplicates">
+            <summary>+{{ report.duplicates|length }} more thread{{ 's' if report.duplicates|length > 1 else '' }}</summary>
+            <ul>
+              {% for dup in report.duplicates %}
+              <li><a href="{{ dup.link }}" title="{{ dup.title }}">{{ dup.title }}</a></li>
+              {% endfor %}
+            </ul>
+          </details>
+          {% endif %}
+        </td>
+        {% if form_template %}
+        <td class="reports-triage">
+          {% include form_template %}
+        </td>
+        {% endif %}
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+{% endfor %}
diff --git a/app/templates/incubator.html b/app/templates/incubator.html
new file mode 100644
index 0000000..b8cb5b7
--- /dev/null
+++ b/app/templates/incubator.html
@@ -0,0 +1,74 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+Currently open security reports for incubating projects
+{% endblock title %}
+{% block page_title %}
+Currently open security reports for incubating projects
+{% endblock page_title %}
+
+{% block content %}
+<p>
+This dashboard gives an overview of the security reports currently tracked by the Apache Security Team for the podlings of the <b>incubator</b>.
+</p>
+
+<p>
+Information on this page may be delayed by as much as a few days.
+If you see anything that seems incorrect, email
+<a href="mailto:security@apache.org">security@apache.org</a>
+</p>
+
+{% with messages = get_flashed_messages(with_categories=true) %}
+  {% if messages %}
+    {% for category, message in messages %}
+      <p class="banner banner-{{ category }}">{{ message }}</p>
+    {% endfor %}
+  {% endif %}
+{% endwith %}
+
+<h2>Summary</h2>
+<table class="reports-table incubator-table sortable-table">
+  <thead>
+    <tr>
+      <th><button type="button">Podling</button></th>
+      <th aria-sort="descending" data-sort-type="number" title="{{ untriaged_description }}"><button type="button">Untriaged</button></th>
+      <th data-sort-type="number"><button type="button">Total</button></th>
+      <th><button type="button">Oldest untriaged</button></th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for podling, untriaged, total, oldest in summary %}
+    <tr>
+      <td><a href="#{{ podling }}">{{ podling }}</a></td>
+      <td class="incubator-count" data-sort-value="{{ untriaged }}">
+        {%- if untriaged -%}
+          <a href="#{{ podling }}-untriaged">{{ untriaged }}</a>
+        {%- endif -%}
+      </td>
+      <td class="incubator-count">{{ total }}</td>
+      <td>{{ oldest.strftime('%Y-%m-%d') if oldest else "" }}</td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+
+{% for podling, sections in podling_sections|dictsort %}
+  <h2 id="{{ podling }}">{{ podling }}</h2>
+  {% if sections %}
+    {% with project_name=podling,
+             show_subproject=podling in pmcs_with_subprojects,
+             anchor_prefix=podling ~ "-",
+             triage_view="incubator" %}
+      {% include "includes/reports.html" %}
+    {% endwith %}
+  {% else %}
+    <p>No open security reports.</p>
+  {% endif %}
+{% endfor %}
+{% endblock content %}
+
+{% block javascripts %}
+  {{ super() }}
+  <script src="/assets/js/sortable-table.js"></script>
+  <script src="/assets/js/triage.js"></script>
+{% endblock javascripts %}
diff --git a/app/templates/project.html b/app/templates/project.html
index d1820a2..c8c1811 100644
--- a/app/templates/project.html
+++ b/app/templates/project.html
@@ -39,70 +39,7 @@ If you see anything that seems incorrect, email
 
 <h2>Reports</h2>
 {% if sections %}
-  {% for title, description, form_template, reports in sections %}
-    <h3>{{ title }}
-      {% if reports|length > 5 %}
-      ({{ reports|length }})
-      {% endif %}
-    </h3>
-    {% if description %}
-      <p>{{ description }}</p>
-    {% endif %}
-    <table class="reports-table">
-      <tbody>
-        {% for report in reports|sort(attribute='date') %}
-        <tr>
-          <!-- {{ report.security_team_name }} -->
-          <td class="reports-date">{{ report.date.strftime('%Y-%m-%d') }}</td>
-          {% if show_subproject %}
-          <td class="reports-subproject">{{ report.subproject or "" }}</td>
-          {% endif %}
-          <td class="reports-jira">
-            {%- if report.github -%}
-              <a href="{{report.github[1]}}">{{ report.github[0] }}</a>
-            {%- endif -%}
-            {%- if report.jira -%}
-              <a href="https://issues.apache.org/jira/browse/{{report.jira}}">#{{ report.jira.split('-')[-1] }}</a>
-            {%- endif -%}
-          </td>
-          <td class="reports-cves">
-            {%- for cve in report.cves -%}
-              <div><a href="https://cveprocess.apache.org/cve5/{{cve}}">{{ cve }}</a></div>
-            {%- endfor -%}
-            {%- if report.state == 'confirmed' and not report.cves -%}
-              <div><a href="https://cveprocess.apache.org/allocatecve?pmc={{ project_name|urlencode }}&amp;title={{ report.sanitized_title|urlencode }}&amp;messageid={{ report.message_id|urlencode }}&amp;listid={{ report.listid|urlencode }}">allocate CVE</a></div>
-            {%- endif -%}
-          </td>
-          <td class="reports-reporter">
-            {% if report.reporter %}
-              <span class="reporter-initials"
-                    style="background-color: {{ report.reporter.color }};"
-                    title="{{ report.reporter.tooltip }}">{{ report.reporter.initials }}</span>
-            {% endif %}
-          </td>
-          <td class="reports-title">
-            <a href="{{ report.link }}" title="{{ report.title }}">{{ report.title }}</a>
-            {% if report.duplicates %}
-            <details class="reports-duplicates">
-              <summary>+{{ report.duplicates|length }} more thread{{ 's' if report.duplicates|length > 1 else '' }}</summary>
-              <ul>
-                {% for dup in report.duplicates %}
-                <li><a href="{{ dup.link }}" title="{{ dup.title }}">{{ dup.title }}</a></li>
-                {% endfor %}
-              </ul>
-            </details>
-            {% endif %}
-          </td>
-          {% if form_template %}
-          <td class="reports-triage">
-            {% include form_template %}
-          </td>
-          {% endif %}
-        </tr>
-        {% endfor %}
-      </tbody>
-    </table>
-  {% endfor %}
+  {% include "includes/reports.html" %}
 {% else %}
   <center>
     <p class="congrats">Congratulations! Your project is back to 0 open security reports!</p>
diff --git a/app/test_incubator_view.py b/app/test_incubator_view.py
new file mode 100644
index 0000000..15cc3ff
--- /dev/null
+++ b/app/test_incubator_view.py
@@ -0,0 +1,133 @@
+# 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.
+
+"""End-to-end tests of the incubator view, which summarizes the podlings."""
+
+import pytest
+import re
+
+from app.test_project_view import MESSAGE_ID, _CONFIG, _build_app, _login, _write_report, notification_api, sync
+
+_PODLINGS = """\
+pmcs_in_incubator: [alpha, beta, gamma, delta]
+"""
+
+
+@pytest.fixture
+def quart_app(tmp_path, monkeypatch):
+    data_dir = tmp_path / "data"
+    # alpha: 1 untriaged of 1; beta: 2 of 2; gamma: 1 of 3; delta: none
+    _write_report(data_dir, "alpha", "2024-03-01 a flaw")
+    _write_report(data_dir, "beta", "2024-03-01 a flaw")
+    _write_report(data_dir, "beta", "2024-03-02 another flaw")
+    _write_report(data_dir, "gamma", "2024-03-01 a flaw")
+    _write_report(data_dir, "gamma", "CVE-2024-1234 a flaw")
+    _write_report(data_dir, "gamma", "CVE-2024-5678 another flaw")
+    return _build_app(tmp_path, monkeypatch, config=_CONFIG + _PODLINGS)
+
+
+async def _incubator_page(quart_app, monkeypatch):
+    _login(monkeypatch, committees=("incubator",))
+    response = await quart_app.test_client().get("/project/incubator")
+    assert response.status_code == 200
+    return await response.get_data(as_text=True)
+
+
+def _summary(body):
+    return body[body.index('<table class="reports-table incubator-table'):body.index("</table>")]
+
+
+@sync
+async def test_summary_is_sorted_by_untriaged_then_total(quart_app, monkeypatch):
+    body = await _incubator_page(quart_app, monkeypatch)
+
+    assert re.findall(r'<td><a href="#(\w+)">', _summary(body)) == ["beta", "gamma", "alpha", "delta"]
+
+
+@sync
+async def test_summary_counts_untriaged_and_total_reports(quart_app, monkeypatch):
+    body = await _incubator_page(quart_app, monkeypatch)
+    rows = _summary(body).split("<tr>")[2:]
+    gamma = next(row for row in rows if 'href="#gamma"' in row)
+    delta = next(row for row in rows if 'href="#delta"' in row)
+
+    assert '<a href="#gamma-untriaged">1</a>' in gamma
+    assert '<td class="incubator-count">3</td>' in gamma
+    assert "<td>2023-11-14</td>" in gamma
+    assert '<td class="incubator-count">0</td>' in delta
+    assert "<td></td>" in delta
+
+
+@sync
+async def test_podling_reports_are_listed_under_anchors(quart_app, monkeypatch):
+    body = await _incubator_page(quart_app, monkeypatch)
+
+    assert '<h2 id="gamma">gamma</h2>' in body
+    assert '<h3 id="gamma-untriaged">' in body
+    assert '<h3 id="gamma-confirmed">' in body
+    assert "No open security reports." in body[body.index('<h2 id="delta">'):]
+
+
+_TRIAGE_FORM = {"message_id": MESSAGE_ID, "action": "accept", "tag": "2024-03-01 a flaw", "feedback": "agreed"}
+
+
+@sync
+async def test_incubator_view_has_triage_forms_that_return_to_it(quart_app, monkeypatch):
+    body = await _incubator_page(quart_app, monkeypatch)
+
+    assert 'action="/api/project/alpha/triage?view=incubator"' in body
+    assert '<script src="/assets/js/triage.js"></script>' in body
+
+
+@sync
+async def test_incubator_member_triages_a_podling(quart_app, monkeypatch, notification_api):
+    _login(monkeypatch, committees=("incubator",))
+    client = quart_app.test_client()
+    response = await client.post("/api/project/alpha/triage?view=incubator", form=_TRIAGE_FORM)
+
+    assert response.status_code == 303
+    assert response.headers["Location"].endswith("/project/incubator")
+    assert [(d.project, d.uid) for d in notification_api.decisions] == [("alpha", "jdoe")]
+    body = await (await client.get("/project/incubator")).get_data(as_text=True)
+    assert '<p class="banner banner-success">' in body
+
+
+@sync
+async def test_incubator_member_opens_podling_pages(quart_app, monkeypatch):
+    _login(monkeypatch, committees=("incubator",))
+    client = quart_app.test_client()
+
+    assert (await client.get("/project/alpha")).status_code == 200
+    # ...but not those of other projects
+    assert (await client.get("/project/cassandra")).status_code != 200
+
+
+@sync
+async def test_view_is_ignored_outside_the_incubator(quart_app, monkeypatch):
+    _login(monkeypatch, committees=("cassandra",))
+    response = await quart_app.test_client().post("/api/project/cassandra/triage?view=incubator", form=_TRIAGE_FORM)
+
+    assert response.headers["Location"].endswith("/project/cassandra")
+
+
+@sync
+async def test_project_view_sections_have_anchors(quart_app, monkeypatch):
+    _login(monkeypatch, committees=("cassandra",))
+    response = await quart_app.test_client().get("/project/cassandra")
+    body = await response.get_data(as_text=True)
+
+    assert '<h3 id="untriaged">' in body
diff --git a/config.yaml.example b/config.yaml.example
index 9dba744..1741707 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -10,6 +10,9 @@ pmcs_using_jira:
 pmcs_with_security_emails:
   - airflow
   - ambari
+pmcs_in_incubator:
+  - amoro
+  - seata
 pmcs_with_subprojects:
   - commons
 # Retired PMCs whose reports the security team handles directly;