asf-tooling commented on issue #1210:
URL:
https://github.com/apache/tooling-trusted-releases/issues/1210#issuecomment-4409674819
<!-- gofannon-issue-triage-bot v2 -->
**Automated triage** — analyzed at `main@2da7807a`
**Type:** `new_feature` • **Classification:** `actionable` •
**Confidence:** `medium`
**Application domain(s):** `project_committee_management`,
`admin_operations`, `release_lifecycle`
### Summary
Issue #1210 requests adding metadata fields to the Committee model
(specifically 'permission to use CI in building release artifacts') and an
admin interface to manage these settings. The Committee model lives in
atr/models/sql.py (not provided in full but referenced throughout). Admin route
patterns in atr/admin/__init__.py show the established form+GET/POST pattern.
The feature requires: (1) a schema migration adding a field like `ci_enabled`
to sql.Committee, (2) an admin form/route pair to toggle this setting, and (3)
potentially surfacing it in the committee view page.
### Where this lives in the code today
#### `atr/datasources/apache.py` — `_update_committees` (lines 374-406)
_currently does this_
Shows the current committee fields being set during metadata sync; new CI
metadata field must not be overwritten here.
```python
async def _update_committees(
data: db.Session, ldap_projects: LDAPProjectsData, committees_by_name:
Mapping[str, Committee]
) -> tuple[int, int]:
added_count = 0
updated_count = 0
# First create PMC committees
for project in ldap_projects.projects:
name = project.name
# Skip non-PMC committees
if project.pmc is not True:
continue
# Get or create PMC
committee = await data.committee(key=name).get()
if not committee:
committee = sql.Committee(key=name)
data.add(committee)
added_count += 1
else:
updated_count += 1
committee.committee_members = project.owners
committee.committers = project.members
# We create PMCs for now
committee.is_podling = False
committee_info = committees_by_name.get(name)
if committee_info:
committee.name = committee_info.display_name
updated_count += 1
return added_count, updated_count
```
#### `atr/admin/__init__.py` — `DeleteCommitteeKeysForm` (lines 76-78)
_extension point_
Establishes the pattern for admin forms that operate on committees with
SELECT widget for committee choice.
```python
class DeleteCommitteeKeysForm(form.Form):
committee_key: str = form.label("Committee", widget=form.Widget.SELECT)
confirm_delete: Literal["DELETE KEYS"] = form.label("Confirmation",
"Type DELETE KEYS to confirm.")
```
#### `atr/admin/__init__.py` — `delete_committee_keys_get` (lines 145-171)
_extension point_
Demonstrates the pattern for admin GET routes that render forms with
committee selection.
```python
@admin.typed
async def delete_committee_keys_get(
_session: web.Committer, _delete_committee_keys:
Literal["delete-committee-keys"]
) -> str | web.WerkzeugResponse:
"""
URL: GET /delete-committee-keys
Display the form to delete committee keys.
"""
async with db.session() as data:
all_committees = await
data.committee(_public_signing_keys=True).order_by(sql.Committee.key).all()
committees_with_keys = [c for c in all_committees if
c.public_signing_keys]
committee_choices = [(c.key, c.display_name) for c in
committees_with_keys]
rendered_form = await form.render(
model_cls=DeleteCommitteeKeysForm,
submit_label="Delete all keys for selected committee",
defaults={"committee_key": committee_choices},
)
return await template.render(
"admin-form.html",
title="Delete committee keys",
description="Delete committee keys",
header="Delete all keys for a committee",
form=rendered_form,
)
```
#### `atr/get/committees.py` — `view` (lines 35-70)
_needs modification_
Committee view page will likely need to display the new CI permission
metadata.
```python
@get.typed
async def view(session: web.Public, _committees: Literal["committees"],
name: safe.CommitteeKey) -> str:
"""
URL: /committees/<name>
"""
# TODO: Could also import this from keys.py
async with db.session() as data:
committee = await data.committee(
key=str(name),
_projects=True,
_public_signing_keys=True,
).demand(base.ASFQuartException(f"Committee {name!s} not found",
errorcode=404))
project_list = list(committee.projects)
committee_member = False
if isinstance(session, web.Committer):
committee_member = await
session.prevent_confusing_ui_display_committee(name, False)
for project in project_list:
# Workaround for the usual loading problem
project.committee = committee
return await template.render(
"committee-view.html",
committee=committee,
projects=project_list,
algorithms=shared.algorithms,
now=datetime.datetime.now(datetime.UTC),
email_from_key=util.email_from_uid,
is_committee_member=committee_member,
update_committee_keys_form=await form.render(
model_cls=shared.keys.UpdateCommitteeKeysForm,
action=util.as_url(post.keys.keys),
submit_label="Regenerate KEYS file",
defaults={"committee_key": committee.key},
empty=True,
),
is_standing=util.committee_is_standing(committee.key),
)
```
#### `atr/datasources/apache.py` — `_update_tooling` (lines 506-535)
_currently does this_
Shows additional Committee fields (release_managers) and pattern of setting
metadata — confirms the model already has multiple metadata fields.
```python
async def _update_tooling(data: db.Session) -> tuple[int, int]:
added_count = 0
updated_count = 0
# Tooling is not a committee
# We add a special entry for Tooling, pretending to be a PMC, for
debugging and testing
tooling_committee = await data.committee(key="tooling").get()
if not tooling_committee:
tooling_committee = sql.Committee(key="tooling", name="Tooling")
data.add(tooling_committee)
tooling_project = sql.Project(key="tooling", name="Apache Tooling",
committee=tooling_committee)
data.add(tooling_project)
added_count += 1
else:
updated_count += 1
additional = config.get().TOOLING_USERS_ADDITIONAL
if additional:
extra = set(additional.split(","))
else:
extra = set()
# Update Tooling PMC data
tooling_users = list(await ldap.fetch_tooling_users(extra))
tooling_committee.committee_members = tooling_users
tooling_committee.committers = tooling_users
tooling_committee.release_managers = tooling_users
tooling_committee.is_podling = False
return added_count, updated_count
```
### Where new code would go
- `atr/models/sql.py` — within Committee class definition
New boolean/JSON field (e.g., ci_enabled) must be added to the Committee
SQLModel.
- `atr/admin/__init__.py` — after delete_committee_keys_post
New admin form class and GET/POST route pair for updating committee
metadata.
### Proposed approach
The implementation requires three parts:
1. **Schema change**: Add a `ci_enabled: bool = False` field (or a more
flexible JSON metadata field) to the `sql.Committee` model in
`atr/models/sql.py`. Since SQLite with SQLModel is used, this will need a
database migration or ALTER TABLE. The field should default to `False` so
existing committees are unaffected.
2. **Admin route**: Following the established pattern (e.g.,
`delete_committee_keys_get/post`), create a new `UpdateCommitteeMetadataForm`
with a committee selector and a checkbox for CI permission, along with
corresponding GET and POST admin routes. The POST handler would load the
committee from the DB and update the `ci_enabled` field.
3. **Display**: The committee view page (`atr/get/committees.py:view`) and
template should surface this metadata for admin/PMC users.
Importantly, the `_update_committees` function in
`atr/datasources/apache.py` must NOT overwrite the manually-set `ci_enabled`
field during metadata sync from Whimsy/LDAP.
### Suggested patches
#### `atr/models/sql.py`
Add ci_enabled field to Committee model (exact location within class
unknown, proposed as addition)
````diff
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ # Within the Committee class definition, add:
+ # Whether this committee is permitted to use CI for building release
artifacts
+ ci_enabled: bool = sqlmodel.Field(default=False)
````
#### `atr/admin/__init__.py`
Add admin form and GET/POST routes for updating committee CI metadata
````diff
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -72,6 +72,11 @@ class DeleteCommitteeKeysForm(form.Form):
confirm_delete: Literal["DELETE KEYS"] = form.label("Confirmation",
"Type DELETE KEYS to confirm.")
+class UpdateCommitteeMetadataForm(form.Form):
+ committee_key: str = form.label("Committee", widget=form.Widget.SELECT)
+ ci_enabled: bool = form.label("CI enabled", "Allow this committee to
use CI for building release artifacts.")
+
+
class DeleteReleaseForm(form.Form):
releases_to_delete: form.StrList = form.label("Select releases to
delete", widget=form.Widget.CUSTOM)
confirm_delete: Literal["DELETE"] = form.label("Confirmation", "Type
DELETE to confirm.")
@@ # After delete_committee_keys_post, add new route pair:
+
[email protected]
+async def committee_metadata_get(
+ _session: web.Committer, _committee_metadata:
Literal["committee-metadata"]
+) -> str | web.WerkzeugResponse:
+ """
+ URL: GET /committee-metadata
+
+ Display the form to update committee metadata.
+ """
+ async with db.session() as data:
+ all_committees = await
data.committee().order_by(sql.Committee.key).all()
+
+ committee_choices = [(c.key, c.display_name) for c in all_committees]
+
+ rendered_form = await form.render(
+ model_cls=UpdateCommitteeMetadataForm,
+ submit_label="Update committee metadata",
+ defaults={"committee_key": committee_choices},
+ )
+ return await template.render(
+ "admin-form.html",
+ title="Update committee metadata",
+ description="Update committee metadata",
+ header="Update committee metadata",
+ form=rendered_form,
+ )
+
+
[email protected]
+async def committee_metadata_post(
+ session: web.Committer,
+ _committee_metadata: Literal["committee-metadata"],
+ metadata_form: UpdateCommitteeMetadataForm,
+) -> str | web.WerkzeugResponse:
+ """
+ URL: POST /committee-metadata
+
+ Update metadata for a committee.
+ """
+ committee_key = metadata_form.committee_key
+
+ async with db.session() as data:
+ async with data.begin():
+ committee = await data.committee(key=committee_key).get()
+ if not committee:
+ await quart.flash(f"Committee '{committee_key}' not
found.", "error")
+ return await session.redirect(committee_metadata_get)
+ committee.ci_enabled = metadata_form.ci_enabled
+
+ await quart.flash(
+ f"Updated metadata for '{committee_key}': CI {'enabled' if
metadata_form.ci_enabled else 'disabled'}.",
+ "success",
+ )
+ return await session.redirect(committee_metadata_get)
````
### Open questions
- The exact structure of the Committee model in atr/models/sql.py is not
visible — need to confirm where to add the field and whether a database
migration strategy (Alembic or manual ALTER TABLE) is needed.
- Should `ci_enabled` be a simple boolean, or should the metadata be more
structured (e.g., a JSON field allowing specification of which CI systems,
repositories, or workflows are permitted)?
- Should the form also allow configuring other future metadata fields, or
should each field get its own admin route?
- How does the `ci_enabled` flag interact with the GitHub Actions OIDC
trusted publishing flow in atr/ssh.py and atr/models/github.py — should those
check this flag before allowing CI-based releases?
### Files examined
- `atr/admin/__init__.py`
- `atr/blueprints/admin.py`
- `atr/datasources/apache.py`
- `atr/get/committees.py`
---
*Draft from a triage agent. A human reviewer should validate before merging
any change. The agent did not run tests or verify diffs apply.*
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]